diff --git a/PyAres/Analyzing/__init__.py b/PyAres/Analyzing/__init__.py index 1d934f2..3a485f4 100644 --- a/PyAres/Analyzing/__init__.py +++ b/PyAres/Analyzing/__init__.py @@ -1,8 +1,8 @@ from .analysis_service import AresAnalyzerService -from .analyzer_models import Analysis, AnalysisRequest, InfoResponse +from .analyzer_models import AnalysisResponse, AnalysisRequest, InfoResponse __all__ = [ - "Analysis", + "AnalysisResponse", "AnalysisRequest", "InfoResponse", "AresAnalyzerService", diff --git a/PyAres/Analyzing/analysis_service.py b/PyAres/Analyzing/analysis_service.py index 96ba532..ef2cedf 100644 --- a/PyAres/Analyzing/analysis_service.py +++ b/PyAres/Analyzing/analysis_service.py @@ -1,7 +1,7 @@ # Standard Imports import grpc from concurrent import futures -from typing import Callable, Awaitable, Union, Mapping, Dict, Optional +from typing import Callable, Awaitable, Union, Mapping, Dict, Optional, Any # Import generated protobuf and gRPC stubs from ares_datamodel.analyzing.remote import ares_remote_analyzer_service_pb2 as analyzer_service @@ -11,7 +11,6 @@ from ares_datamodel.connection import connection_state_pb2 from ares_datamodel.connection import connection_status_pb2 from ares_datamodel.connection import connection_info_pb2 -from ares_datamodel import ares_data_type_pb2 from ares_datamodel import ares_data_schema_pb2 from ares_datamodel import ares_outcome_enum_pb2 @@ -19,14 +18,14 @@ from ..Utils import ares_struct_utils from ..Utils import ares_data_schema_utils from ..Utils import ares_outcome_utils +from ..Utils import ares_value_utils # Import python models -from ..Models import ares_data_models, RequestMetadata -from ..Models import AresSchemaEntry -from .analyzer_models import AnalysisRequest, Analysis, InfoResponse +from ..Models import ares_data_models, RequestMetadata, Limits, AresSchemaEntry +from .analyzer_models import AnalysisRequest, AnalysisResponse, InfoResponse # Type hints for the user's custom logic -AnalyzeLogicFunction = Callable[[AnalysisRequest], Union[Analysis, Awaitable[Analysis]]] +AnalyzeLogicFunction = Callable[[AnalysisRequest], Union[AnalysisResponse, Awaitable[AnalysisResponse]]] class AresAnalyzerServiceWrapper(analyzer_service_grpc.AresRemoteAnalyzerServiceServicer): """ @@ -73,7 +72,7 @@ def Analyze(self, request: analyzer_service.AnalysisRequest, context) -> analysi if isinstance(python_response, Awaitable): python_response = python_response.__await__() - if not isinstance(python_response, Analysis): + if not isinstance(python_response, AnalysisResponse): print("Analysis response was an invalid type, ") proto_analysis.analysis_outcome = ares_outcome_enum_pb2.FAILURE proto_analysis.error_string = "The user's custom analysis logic returned an invalid type, analysis cannot be processed" @@ -200,7 +199,15 @@ def __init__(self, else: self._server.add_insecure_port(f'[::]:{self._port}') - def add_setting(self, setting_name: str, setting_type: ares_data_models.AresDataType, optional: bool = True, constraints: Union[list[int], list[str], list[float]] = [], struct_schema: Optional[Dict[str, AresSchemaEntry]] = None): + def add_setting(self, + setting_name: str, + setting_type: ares_data_models.AresDataType, + default_value: Any = None, + optional: bool = True, + constraints: Union[list[int], list[str], list[float]] = [], + struct_schema: Optional[Dict[str, AresSchemaEntry]] = None, + limits: Optional[Limits] = None, + description: Optional[str] = None): """ Adds an analyzer setting to be reported to ARES when capabilities are requested. While most `PyAres.Models.AresDataType` options are supported, bool arrays and byte arrays @@ -212,8 +219,30 @@ def add_setting(self, setting_name: str, setting_type: ares_data_models.AresData optional (bool): Whether the setting is optional. constraints: An optional list of values to constrain the available setting choices. Can be integers, strings, or floats. struct_schema: An optional dictionary defining the fields of a STRUCT type setting, using AresSchemaEntry objects. + limits: An optional limits object used to specify minimum and maximum values for a setting + description: An optional string to describe your setting in more detail. Appears in ARES as a tooltip in the settings menu. """ - self._service_wrapper._settings[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type, optional, constraints, struct_schema) + try: + if default_value is not None: + default_ares_value = ares_value_utils.create_ares_value(default_value) + self._service_wrapper._settings[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type=setting_type, + optional=optional, + choices=constraints, + struct_schema=struct_schema, + limits=limits, + default_value=default_ares_value, + description=description) + + else: + self._service_wrapper._settings[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type=setting_type, + optional=optional, + choices=constraints, + struct_schema=struct_schema, + limits=limits, + description=description) + + except Exception as e: + print(f"Encountered an exception while adding setting {setting_name}: {e}") def add_analysis_parameter(self, parameter_name: str, parameter_type: ares_data_models.AresDataType, optional: bool = False, struct_schema: Optional[Dict[str, AresSchemaEntry]] = None): """ @@ -247,7 +276,7 @@ def start(self, wait_for_termination: bool = True): Setting this value to false will allow you to continue execution after starting your service, however this should ONLY be done if you have another mechanism for keeping your process alive (such as a GUI, or a loop). Defaults to true. """ - print(f"Starting Ares Device Service on port {self._port}...") + print(f"Starting Ares Analysis Service on port {self._port}...") self._server.start() if wait_for_termination: diff --git a/PyAres/Analyzing/analyzer_models.py b/PyAres/Analyzing/analyzer_models.py index 1fe64f6..2fd90aa 100644 --- a/PyAres/Analyzing/analyzer_models.py +++ b/PyAres/Analyzing/analyzer_models.py @@ -20,7 +20,7 @@ def __str__(self) -> str: def __repr__(self) -> str: return self.__str__() -class Analysis: +class AnalysisResponse: """ Represents the result of an analysis process. """ def __init__(self, result: float, outcome: Outcome = Outcome.SUCCESS, error_string: str = ""): diff --git a/PyAres/Demo/Analyzers/airship_analyzer.py b/PyAres/Demo/Analyzers/airship_analyzer.py new file mode 100644 index 0000000..8e9be73 --- /dev/null +++ b/PyAres/Demo/Analyzers/airship_analyzer.py @@ -0,0 +1,20 @@ +from PyAres import AresAnalyzerService, AnalysisRequest, Analysis, AresDataType, Outcome + +def analysis_logic(request: AnalysisRequest): + result = int(request.inputs["ShotOutcome"]) + print(f"Analyzing, result is: {result}") + response = Analysis(result=result, outcome=Outcome.SUCCESS) + return response + +if __name__ == "__main__": + print("Welcome to the Airship Analyzer") + + analyzer = AresAnalyzerService( + custom_analysis_logic=analysis_logic, + description="An analyzer for use with the Airship Device", + name="Airship Analyzer", + version="1.0.0") + + analyzer.add_analysis_parameter("ShotOutcome", AresDataType.NUMBER) + + analyzer.start() diff --git a/PyAres/Demo/analyzer_test.py b/PyAres/Demo/Analyzers/analyzer_test.py similarity index 54% rename from PyAres/Demo/analyzer_test.py rename to PyAres/Demo/Analyzers/analyzer_test.py index 019be1b..8debc6f 100644 --- a/PyAres/Demo/analyzer_test.py +++ b/PyAres/Demo/Analyzers/analyzer_test.py @@ -1,16 +1,19 @@ -from PyAres import AresAnalyzerService, AnalysisRequest, Analysis, AresDataType, Outcome +from PyAres import AresAnalyzerService, AnalysisRequest, AnalysisResponse, AresDataType, Outcome, Limits -def analyze(request: AnalysisRequest) -> Analysis: +def analyze(request: AnalysisRequest) -> AnalysisResponse: #Custom Analysis Logic - temperature = request.inputs.get("Temperature") - - if not isinstance(temperature, float): - print("Temperature was not a float") - temperature = 0.0 - - print(f"Temperature: {temperature}") - - analysis = Analysis(result=temperature) + temp_one = request.inputs.get("Temperature One") + print("Processing Analysis Request") + + if not isinstance(temp_one, float): + print("Temperature One was not a float") + print(temp_one) + temp_one = 0.0 + + else: + print(f"Temperature One: {temp_one}") + + analysis = AnalysisResponse(result=temp_one) return analysis @@ -23,7 +26,5 @@ def analyze(request: AnalysisRequest) -> Analysis: #Add Analysis Parameters pythonDemoAnalyzer.add_analysis_parameter("Temperature", AresDataType.NUMBER) - pythonDemoAnalyzer.add_setting(setting_name="", setting_type=AresDataType.NULL, optional=True, constraints=[]) - pythonDemoAnalyzer.start(wait_for_termination=True) - + pythonDemoAnalyzer.add_setting(setting_name="", setting_type=AresDataType.NULL, optional=True, constraints=[], limits=Limits(0, 10000)) pythonDemoAnalyzer.start() \ No newline at end of file diff --git a/PyAres/Demo/analyzer_test_tools_demo.py b/PyAres/Demo/Analyzers/analyzer_test_tools_demo.py similarity index 100% rename from PyAres/Demo/analyzer_test_tools_demo.py rename to PyAres/Demo/Analyzers/analyzer_test_tools_demo.py diff --git a/PyAres/Demo/analyzer_wiki.py b/PyAres/Demo/Analyzers/analyzer_wiki.py similarity index 54% rename from PyAres/Demo/analyzer_wiki.py rename to PyAres/Demo/Analyzers/analyzer_wiki.py index d3f8077..e4ae09a 100644 --- a/PyAres/Demo/analyzer_wiki.py +++ b/PyAres/Demo/Analyzers/analyzer_wiki.py @@ -1,21 +1,20 @@ -from PyAres import AresAnalyzerService, AnalysisRequest, Analysis, AresDataType, Outcome +from PyAres import AresAnalyzerService, AnalysisRequest, AnalysisResponse, AresDataType, Outcome, Limits -def analyze_sample(request: AnalysisRequest) -> Analysis: +def analyze_sample(request: AnalysisRequest) -> AnalysisResponse: # 1. Extract inputs # 'Growth_Metric' would come from a sensor or previous step raw_value = request.inputs.get("Growth_Metric") if raw_value is None: - return Analysis(result=0.0, outcome=Outcome.FAILURE) + return AnalysisResponse(result=0.0, outcome=Outcome.FAILURE) # 2. Perform Logic print(f"Analyzing sample with value: {raw_value}") - calculated_score = raw_value * 1.5 # Placeholder logic - is_success = calculated_score > 10.0 # Define success criteria + calculated_score = raw_value * 1.5 # 3. Return Result - return Analysis(result=calculated_score, outcome=Outcome.SUCCESS) + return AnalysisResponse(result=calculated_score, outcome=Outcome.SUCCESS) if __name__ == "__main__": service = AresAnalyzerService( @@ -27,5 +26,10 @@ def analyze_sample(request: AnalysisRequest) -> Analysis: # Define what data we need from ARES service.add_analysis_parameter("Growth_Metric", AresDataType.NUMBER) + + service.add_setting("Random Setting", AresDataType.NUMBER, + default_value=250, + limits=Limits(1, 500), + description="This is a random setting, it is purely for demonstration purposes") service.start() \ No newline at end of file diff --git a/PyAres/Demo/device_test.py b/PyAres/Demo/Devices/device_test.py similarity index 85% rename from PyAres/Demo/device_test.py rename to PyAres/Demo/Devices/device_test.py index 1cd1b1f..1c531be 100644 --- a/PyAres/Demo/device_test.py +++ b/PyAres/Demo/Devices/device_test.py @@ -11,10 +11,10 @@ def __init__(self): def set_temperature(self, temperature: float): self.temperature = temperature time.sleep(5) - return {} + return DeviceCommandResponse(None, status_code=StatusCode.COMMAND_SUCCESS) def get_temperature(self): - return { "temperature": self.temperature } + return DeviceCommandResponse(self.temperature, status_code=StatusCode.COMMAND_SUCCESS) def get_device_state(self) -> Dict: state_dict = { "temperature": self.temperature } @@ -45,6 +45,6 @@ def enter_safe_mode(self): device_service.add_new_command(get_temp_desc, device.get_temperature) #Add Settings - device_service.add_setting("Allow Negative Values", True) + device_service.add_setting("Allow Negative Values", True, description="A boolean value that determines whether the test device allows negative values") device_service.start() \ No newline at end of file diff --git a/PyAres/Demo/test_device.py b/PyAres/Demo/Devices/failure_test_device.py similarity index 96% rename from PyAres/Demo/test_device.py rename to PyAres/Demo/Devices/failure_test_device.py index 4a6b4e5..d369ec0 100644 --- a/PyAres/Demo/test_device.py +++ b/PyAres/Demo/Devices/failure_test_device.py @@ -3,7 +3,7 @@ from PyAres import AresDeviceService, AresDataType, DeviceCommandDescriptor, DeviceSchemaEntry -class TestDevice: +class FailureTestDevice: def fail(self): """Intentionally fails so command failure handling can be tested.""" print("[Test Device] Running intentionally failing command...") @@ -26,7 +26,7 @@ def safe_mode(self): if __name__ == "__main__": - test_device = TestDevice() + test_device = FailureTestDevice() service = AresDeviceService( test_device.safe_mode, diff --git a/PyAres/Demo/hotplate.py b/PyAres/Demo/Devices/hotplate.py similarity index 90% rename from PyAres/Demo/hotplate.py rename to PyAres/Demo/Devices/hotplate.py index 42901d2..3e662c0 100644 --- a/PyAres/Demo/hotplate.py +++ b/PyAres/Demo/Devices/hotplate.py @@ -1,4 +1,4 @@ -from PyAres import AresDeviceService, AresDataType, DeviceSchemaEntry, DeviceCommandDescriptor +from PyAres import * # --- PART 1: The Simulated Hardware --- class VirtualHotplate: @@ -8,14 +8,16 @@ def __init__(self): def set_temperature(self, temp: float): """Simulates setting the heater.""" print(f"[Hardware] Heating to {temp}°C...") + response = DeviceCommandResponse(None, status_code=StatusCode.COMMAND_SUCCESS) self.target_temp = temp - return {} # Return empty dict if no data needs to be sent back + return response def get_temperature(self): """Simulates reading the sensor.""" + response = DeviceCommandResponse({ "current_temp": self.target_temp }, status_code=StatusCode.COMMAND_SUCCESS) # In a real device, you'd read a serial port here. print("[Hardware] Retrieving the current temperature...") - return { "current_temp": self.target_temp } + return response def get_state(self): """Required: Tells ARES the current status for logging.""" diff --git a/PyAres/Demo/random_number_device.py b/PyAres/Demo/Devices/random_number_device.py similarity index 91% rename from PyAres/Demo/random_number_device.py rename to PyAres/Demo/Devices/random_number_device.py index d460026..7bbc53a 100644 --- a/PyAres/Demo/random_number_device.py +++ b/PyAres/Demo/Devices/random_number_device.py @@ -1,6 +1,6 @@ import random -from PyAres import AresDeviceService, AresDataType, DeviceSchemaEntry, DeviceCommandDescriptor +from PyAres import AresDeviceService, AresDataType, DeviceSchemaEntry, DeviceCommandDescriptor, DeviceCommandResponse, StatusCode # --- PART 1: The Simulated Hardware --- @@ -12,7 +12,8 @@ def generate_number(self): """Simulates reading a random value from hardware.""" self.last_number = random.randint(1, 100) print(f"[Hardware] Generated random number: {self.last_number}") - return {"random_number": self.last_number} + response = DeviceCommandResponse({"random_number": self.last_number}, status_code=StatusCode.COMMAND_SUCCESS) + return response def get_state(self): """Required: Tells ARES the current status for logging.""" diff --git a/PyAres/Demo/rotary_mixer.py b/PyAres/Demo/Devices/rotary_mixer.py similarity index 70% rename from PyAres/Demo/rotary_mixer.py rename to PyAres/Demo/Devices/rotary_mixer.py index 4f15dea..1d41342 100644 --- a/PyAres/Demo/rotary_mixer.py +++ b/PyAres/Demo/Devices/rotary_mixer.py @@ -1,4 +1,4 @@ -from PyAres import AresDeviceService, AresDataType, DeviceSchemaEntry, DeviceCommandDescriptor +from PyAres import * class RotaryMixer: def __init__(self, speed): @@ -11,12 +11,11 @@ def __init__(self, speed): def set_speed(rpm: float): print(f"Setting motor speed to {rpm}") mixer.speed = rpm - # Hardware communication goes here... - return {} # Return empty dict if no data needs to be sent back + return DeviceCommandResponse(None, status_code=StatusCode.COMMAND_SUCCESS) # Return empty dict if no data needs to be sent back def get_speed(): print("Hey I got speed") - return { "rpm": mixer.speed } + return DeviceCommandResponse(mixer.speed, status_code=StatusCode.COMMAND_SUCCESS) def get_status(): # Return a dictionary matching your state schema @@ -37,14 +36,10 @@ def safe_mode(): # 3. Define the 'Set Speed' Command # Input: One number (Speed) -input_schema = { - "rpm": DeviceSchemaEntry(AresDataType.NUMBER, "Speed in RPM", "RPM") -} +input_schema = { "rpm": DeviceSchemaEntry(AresDataType.NUMBER, "Speed in RPM", "RPM") } cmd_descriptor = DeviceCommandDescriptor("Set Speed", "Sets mixer speed", input_schema, {}) -output_schema = { - "rpm": DeviceSchemaEntry(AresDataType.NUMBER, "Speed in RPM", "RPM") -} +output_schema = { "rpm": DeviceSchemaEntry(AresDataType.NUMBER, "Speed in RPM", "RPM") } get_speed_descriptor = DeviceCommandDescriptor("Get Speed", "Gets mixer speed", {}, output_schema) # 4. Register the command diff --git a/PyAres/Demo/Planners/airship_planner.py b/PyAres/Demo/Planners/airship_planner.py new file mode 100644 index 0000000..f4016cb --- /dev/null +++ b/PyAres/Demo/Planners/airship_planner.py @@ -0,0 +1,187 @@ +from PyAres import AresPlannerService, PlanRequest, PlanResponse, AresDataType +import random +import time +from enum import Enum + +class Planners(Enum): + RANDOM_PLANNER = 1 + TRADITIONAL_PLANNER = 2 + SEARCH_AND_DESTROY_PLANNER = 3 + +def coord_to_tuple(coord: str): + """Converts 'B3' to (1, 2).""" + return (ord(coord[0].upper()) - ord('A'), int(coord[1:]) - 1) + +def tuple_to_coord(tup: tuple): + """Converts (1, 2) to 'B3'.""" + return f"{chr(ord('A') + tup[0])}{tup[1] + 1}" + +def generate_all_coords(board_size=10): + """Generates all coordinates from 'A1' to 'J10'.""" + return [tuple_to_coord((r, c)) for r in range(board_size) for c in range(board_size)] + +def convert_param_history(param_history: list): + converted_history = [] + for value in param_history: + letter = value.planned_value[0] + converted_value = ord(letter) + 1 - ord('A') + converted_history.append((converted_value, int(value.planned_value[1:]))) + return converted_history + +def get_random_shot(param_history: list): + """ Chooses a random, un-shot-at coordinate. """ + converted_param_history = convert_param_history(param_history) + all_possible_shots = [(c, r) for r in range(1,11) for c in range(1, 11)] + available_shots = list(set(all_possible_shots) - set(converted_param_history)) + print(f"{len(available_shots)}/{len(all_possible_shots)}") + + if not available_shots: + return None + + return random.choice(available_shots) + +def get_traditional_search_shot(param_history: list): + """ Uses an extremely basic traditional search algorithm, moving across the board """ + shot_number = len(param_history) + all_possible_shots = [(c, r) for r in range(1,11) for c in range(1, 11)] + return all_possible_shots[shot_number] + +def get_search_and_destroy_shot(request: PlanRequest): + """A 'Hunt/Target' planner for Airship.""" + param = request.parameters[0] + shot_history = [p.planned_value for p in param.param_history] + shot_results = request.analysis_results # 0.0=Miss, 1.0=Hit, 2.0=Sunk + active_hits = [] + + # --- Identify All Active Hits --- + for i in range(len(shot_history)): + coord = shot_history[i] + result = shot_results[i] + + is_sunk = False + for j in range(i, len(shot_history)): + if shot_results[j] == 2.0 and shot_history[j] == coord: + is_sunk = True + active_hits = [] + break + + if result == 1.0 and not is_sunk: + active_hits.append(coord) + + # Get Unique active hits and sort them for consistency + active_hits = sorted(list(set(active_hits))) + next_shot = None + + # --- TARGET MODE --- + if active_hits: + print(f"Target Mode -> Active Hits: {active_hits}") + + is_horizontal = False + is_vertical = False + + if len(active_hits) >= 2: + #Convert the first two active hits to (row, col) tuples + r1, c1 = coord_to_tuple(active_hits[0]) + r2, c2 = coord_to_tuple(active_hits[1]) + + if r1 == r2: + is_horizontal = True + + elif c1 == c2: + is_vertical = True + + potential_targets = [] + + if is_horizontal or is_vertical: + print(f"Orientation Known! {'Horizontal' if is_horizontal else 'Vertical'}") + hit_tuples = [coord_to_tuple(c) for c in active_hits] + + if is_horizontal: + min_col = min(c for r, c in hit_tuples) + max_col = max(c for r, c in hit_tuples) + row = hit_tuples[0][0] + + potential_targets.append((row, min_col - 1)) + potential_targets.append((row, max_col + 1)) + + elif is_vertical: + min_row = min(r for r, c in hit_tuples) + max_row = max(r for r, c in hit_tuples) + col = hit_tuples[0][1] + + potential_targets.append((min_row - 1, col)) + potential_targets.append((max_row + 1, col)) + + else: + active_hit_coord = active_hits[0] + row, col = coord_to_tuple(active_hit_coord) + potential_targets.extend([(row - 1, col), (row + 1, col), (row, col - 1), (row, col + 1)]) + + valid_targets = [] + for r, c in potential_targets: + if 0 <= r < 10 and 0 <= c < 10: + coord = tuple_to_coord((r,c)) + if coord not in shot_history: + valid_targets.append(coord) + + if valid_targets: + next_shot = random.choice(valid_targets) + print(f"Targeting next logical square: {next_shot}") + + # --- HUNT MODE --- + if next_shot is None: + if active_hits: + print("Target mode exhausted (likely cornered the ship). Returning to Hunt Mode... ") + else: + print("Hunt Mode -> Searching for a new target.... ") + + available_shots = list(set(generate_all_coords()) - set(shot_history)) + hunt_candidates = [c for c in available_shots if (coord_to_tuple(c)[0] + coord_to_tuple(c)[1] % 2 == 0)] + + if hunt_candidates: + next_shot = random.choice(hunt_candidates) + + elif available_shots: + next_shot = random.choice(available_shots) + + else: + #Game Over + next_shot = "A1" + + print(f"Planner requesting fire at: {next_shot}") + time.sleep(0.25) + return PlanResponse(parameter_names=[param.name], parameter_values=[next_shot]) + +def plan(request: PlanRequest) -> PlanResponse: + #For an "Airship" game we should only ever have one parameter, which is our coordinate + param = request.parameters[0] + #Get next shot + if(param.planner_name == Planners.RANDOM_PLANNER.name): + shot = get_random_shot(param.param_history) + elif(param.planner_name == Planners.TRADITIONAL_PLANNER.name): + shot = get_traditional_search_shot(param.param_history) + else: + response = get_search_and_destroy_shot(request) + return response + + time.sleep(0.25) + letter = chr(ord('A') - 1 + shot[0]) + shot_string = f"{letter}{shot[1]}" + print(f"Requesting Fire at {shot_string}") + + response = PlanResponse(parameter_names=[param.name], parameter_values=[shot_string]) + return response + +if __name__ == "__main__": + name = "Airship Planner Service" + description = "A planner service that provides some basic algorithms for playing Airship." + planner = AresPlannerService(plan, name, description, "1.0.0", port=8003) + + #Add Supported Types + planner.add_supported_type(AresDataType.STRING) + + planner.add_planner_option(Planners.RANDOM_PLANNER.name, "Randomly shoots at an un-shot-at coordinate", "1.0.0") + planner.add_planner_option(Planners.TRADITIONAL_PLANNER.name, "Follows a very basic traditional search pattern", "1.0.0") + planner.add_planner_option(Planners.SEARCH_AND_DESTROY_PLANNER.name, "Searches for ships with random shots, destroys ships upon locating them.", "1.0.0") + + planner.start() \ No newline at end of file diff --git a/PyAres/Demo/planner_test.py b/PyAres/Demo/Planners/planner_test.py similarity index 85% rename from PyAres/Demo/planner_test.py rename to PyAres/Demo/Planners/planner_test.py index 9e642cd..6c0a838 100644 --- a/PyAres/Demo/planner_test.py +++ b/PyAres/Demo/Planners/planner_test.py @@ -70,13 +70,13 @@ def gradual_planner(param: PlanningParameter) -> float: pythonDemoPlanner.add_planner_option("Gradual Planner", "A planner that gradually increases a value based on the values history", "1.0.0") #Add Planner Settings - pythonDemoPlanner.add_setting("String Setting", AresDataType.STRING) - pythonDemoPlanner.add_setting("Number Setting", AresDataType.NUMBER) - pythonDemoPlanner.add_setting("Boolean Setting", AresDataType.BOOLEAN) - pythonDemoPlanner.add_setting("String Array Setting", AresDataType.STRING_ARRAY) - pythonDemoPlanner.add_setting("Constrained Strings", AresDataType.STRING_ARRAY, True, ["One", "Two", "Three"]) - pythonDemoPlanner.add_setting("Number Array Setting", AresDataType.NUMBER_ARRAY) - pythonDemoPlanner.add_setting("Constrained Numbers", AresDataType.NUMBER_ARRAY, True, [1, 2, 3]) + pythonDemoPlanner.add_setting("String Setting", AresDataType.STRING, default_value="Default String", description="This is a string setting") + pythonDemoPlanner.add_setting("Number Setting", AresDataType.NUMBER, default_value=100.0, description="This is a number setting") + pythonDemoPlanner.add_setting("Boolean Setting", AresDataType.BOOLEAN, description="This is a boolean setting") + pythonDemoPlanner.add_setting("String Array Setting", AresDataType.STRING_ARRAY, description="This is a string array setting") + pythonDemoPlanner.add_setting("Constrained Strings", AresDataType.STRING_ARRAY, optional=True, constraints=["One", "Two", "Three"]) + pythonDemoPlanner.add_setting("Number Array Setting", AresDataType.NUMBER_ARRAY, description="This is a number array setting") + pythonDemoPlanner.add_setting("Constrained Numbers", AresDataType.NUMBER_ARRAY, optional=True, constraints=[1, 2, 3]) #Set Planner Timeout pythonDemoPlanner.set_timeout(60) diff --git a/PyAres/Demo/planner_test_tools_demo.py b/PyAres/Demo/Planners/planner_test_tools_demo.py similarity index 100% rename from PyAres/Demo/planner_test_tools_demo.py rename to PyAres/Demo/Planners/planner_test_tools_demo.py diff --git a/PyAres/Demo/planner_wiki.py b/PyAres/Demo/Planners/planner_wiki.py similarity index 100% rename from PyAres/Demo/planner_wiki.py rename to PyAres/Demo/Planners/planner_wiki.py diff --git a/PyAres/Device/__init__.py b/PyAres/Device/__init__.py index 707f209..dc4fd30 100644 --- a/PyAres/Device/__init__.py +++ b/PyAres/Device/__init__.py @@ -1,3 +1,5 @@ from .device_models import DeviceCommandDescriptor from .device_models import DeviceSchemaEntry +from .device_models import StatusCode +from .device_models import DeviceCommandResponse from .device_service import AresDeviceService \ No newline at end of file diff --git a/PyAres/Device/device_models.py b/PyAres/Device/device_models.py index 370b788..b1ee00a 100644 --- a/PyAres/Device/device_models.py +++ b/PyAres/Device/device_models.py @@ -1,10 +1,15 @@ -from typing import Dict, Union, Optional +from enum import Enum +from dataclasses import dataclass, field +from typing import Dict, Union, Optional, Any from ..Models import ares_data_models class DeviceSchemaEntry: """ A class that describes an input or output parameter for a device command """ - def __init__(self, type: ares_data_models.AresDataType, description: str = "", unit: str = "", optional: bool = False, + def __init__(self, type: ares_data_models.AresDataType, + description: str = "", + unit: str = "", + optional: bool = False, constraints: Union[list[int], list[float], list[str]] = [], quantity_schema: Optional[ares_data_models.QuantitySchema] = None, struct_schema: Optional[Dict[str, 'DeviceSchemaEntry']] = None, @@ -54,3 +59,20 @@ def __init__(self, name: str, description: str, input_schema: Dict[str, DeviceSc self.description = description self.input_schema = input_schema self.output_schema = output_schema + +class StatusCode(Enum): + STATUS_UNSPECIFIED = 0 + COMMAND_SUCCESS = 1 + SUCCESS_WITH_WARNINGS = 2 + COMMAND_FAILED = 3 + INVALID_COMMAND = 4 + HARDWARE_FAULT = 5 + EMERGENCY_STOP = 6 + OUT_OF_RANGE = 7 + PARAMETERS_UNACHIEVEABLE = 8 + +@dataclass +class DeviceCommandResponse: + response: Union[Dict[str, Any], Any] + error_string: str = "" + status_code: StatusCode = StatusCode.STATUS_UNSPECIFIED diff --git a/PyAres/Device/device_service.py b/PyAres/Device/device_service.py index ac6ace1..e7e8503 100644 --- a/PyAres/Device/device_service.py +++ b/PyAres/Device/device_service.py @@ -3,7 +3,7 @@ import time import warnings from concurrent import futures -from typing import Dict, Callable, Awaitable, Union, Any +from typing import Dict, Callable, Awaitable, Union, Any, Optional from ares_datamodel.device.remote import ares_remote_device_service_pb2 as device_service from ares_datamodel.device.remote import ares_remote_device_service_pb2_grpc as device_service_grpc @@ -15,15 +15,19 @@ from google.protobuf import empty_pb2 from .device_models import DeviceCommandDescriptor +from ..Models import Limits +from .device_models import DeviceCommandDescriptor, DeviceCommandResponse, StatusCode from ..Utils import ares_device_command_utils from ..Utils import ares_data_schema_utils from ..Utils import ares_struct_utils from ..Utils import ares_value_utils from ..Utils import ares_data_type_utils +from ..Utils import device_status_code_utils # Type hint for the user's custom methods EnterSafeModeMethod = Callable[[], None] -DeviceCommandMethod = Callable[..., Dict[str, Any]] +AllowedReturns = Union[DeviceCommandResponse, Dict[str, Any], Any] +DeviceCommandMethod = Callable[..., AllowedReturns] DeviceStateMethod = Callable[[], Dict[str, Any]] class AresDeviceServiceWrapper(device_service_grpc.AresRemoteDeviceServiceServicer): @@ -85,18 +89,44 @@ def ExecuteCommand(self, request: device_service.ExecuteCommandRequest, context) provided_param_dict = ares_struct_utils.ares_struct_to_dict(request.arguments) try: result : Dict[str, Any] = method(**provided_param_dict) + except Exception as e: response.success = False response.error = f"Command '{request.command_name}' failed: {e}" return response - if isinstance(result, dict): - for key, value in result.items(): + # Modern devices should respond with a device command response + if isinstance(result, DeviceCommandResponse): + response.status_code = device_status_code_utils.python_status_code_to_proto_status_code(result.status_code) + response.success = device_status_code_utils.determine_success(result.status_code) + + if isinstance(result.response, dict): + for key, value in result.response.items(): ares_struct_utils.add_value_to_struct(response.result.struct_value, key, ares_value_utils.create_ares_value(value)) - else: - response.result.CopyFrom(ares_value_utils.create_ares_value(result)) - response.success = True + else: + response.result.CopyFrom(ares_value_utils.create_ares_value(result.response)) + + # Legacy device responses will only send back the value as the response, ensure backwards compatability + else: + # Keep a backup of the original formatting function + formatwarning_orig = warnings.formatwarning + + # Override it to force the source code line to be empty + warnings.formatwarning = lambda message, category, filename, lineno, line=None: \ + formatwarning_orig(message, category, filename, lineno, line='') + + warnings.warn("Returning raw values or dictionaries directly for device commands is deprecated. The new standard is to return a DeviceCommandResponse object instead. Please consider updating your device to use this standard.", FutureWarning) + + if isinstance(result, dict): + for key, value in result.items(): + ares_struct_utils.add_value_to_struct(response.result.struct_value, key, ares_value_utils.create_ares_value(value)) + + else: + response.result.CopyFrom(ares_value_utils.create_ares_value(result)) + + response.success = True + return response else: @@ -122,6 +152,7 @@ def GetSettingsSchema(self, request, context) -> device_service.SettingsSchemaRe settings_entry = response.schema.fields[key] settings_entry.type = value.type settings_entry.optional = value.optional + settings_entry.description = value.description if len(value.string_choices.strings) != 0: settings_entry.string_choices.strings.extend(value.string_choices.strings) @@ -274,7 +305,13 @@ def add_new_command(self, cmd_descriptor: DeviceCommandDescriptor, method): self._service_wrapper._command_methods[cmd_descriptor.name] = method self._service_wrapper._commands.append(cmd_descriptor) - def add_setting(self, setting_name: str, setting_value: Any, optional: bool = True, constraints: Union[list[int], list[str], list[float]] = []): + def add_setting(self, + setting_name: str, + setting_value: Optional[Any] = None, + optional: bool = True, + constraints: Union[list[int], list[str], list[float]] = [], + limits: Optional[Limits] = None, + description: Optional[str] = None): """ Adds a new device setting to be reported to ARES when your devices capabilities are requested. @@ -283,11 +320,18 @@ def add_setting(self, setting_name: str, setting_value: Any, optional: bool = Tr setting_value (Any): The default value of the setting optional (bool): Whether the setting is optional constraints: An optional list of values to constrain the available setting choices. Can be integers, floats, or strings. + limits: An optional Limits object for specifying minimum and maximum values + description: An optional string to describe your setting in more detail. Appears in ARES as a tooltip in the settings menu. """ - setting_type = ares_data_type_utils.determine_python_ares_data_type(setting_value) - self._service_wrapper._setting_schema[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type, optional, constraints) - new_ares_value = ares_value_utils.create_ares_value(setting_value) - self._service_wrapper._current_settings[setting_name] = new_ares_value + try: + setting_type = ares_data_type_utils.determine_python_ares_data_type(setting_value) + new_ares_value = ares_value_utils.create_ares_value(setting_value) + + self._service_wrapper._setting_schema[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type, optional, choices=constraints, limits=limits, default_value=new_ares_value, description=description) + self._service_wrapper._current_settings[setting_name] = new_ares_value + + except Exception as e: + print(f"Exception when trying to create setting {setting_name}: {e}") def start(self, wait_for_termination: bool = True): """ @@ -299,6 +343,7 @@ def start(self, wait_for_termination: bool = True): Setting this value to false will allow you to continue execution after starting your service, however this should ONLY be done if you have another mechanism for keeping your process alive (such as a GUI, or a loop). Defaults to true. """ + print(f"Starting Ares Device Service on port {self._port}...") self._server.start() diff --git a/PyAres/Models/__init__.py b/PyAres/Models/__init__.py index 525bb74..342beba 100644 --- a/PyAres/Models/__init__.py +++ b/PyAres/Models/__init__.py @@ -1,4 +1,4 @@ -from .ares_data_models import AresDataType, Outcome, RequestMetadata, AresSchemaEntry, Quantity, QuantitySchema +from .ares_data_models import AresDataType, Outcome, RequestMetadata, AresSchemaEntry, Quantity, QuantitySchema, Limits __all__ = [ "AresDataType", @@ -6,5 +6,6 @@ "RequestMetadata", "AresSchemaEntry", "Quantity", - "QuantitySchema" + "QuantitySchema", + "Limits" ] \ No newline at end of file diff --git a/PyAres/Models/ares_data_models.py b/PyAres/Models/ares_data_models.py index 0d34a51..6a06019 100644 --- a/PyAres/Models/ares_data_models.py +++ b/PyAres/Models/ares_data_models.py @@ -56,6 +56,11 @@ def from_default_values(cls): default = request_metadata_pb2.RequestMetadata(system_name="TEST SYSTEM", campaign_name="TEST CAMPAIGN", campaign_id="TEST ID", experiment_id="TEST EXPERIMENT ID") return cls(default) +class Limits: + def __init__(self, minimum: float, maximum: float): + self.minimum = minimum + self.maximum = maximum + @dataclass class Quantity: scalar: float diff --git a/PyAres/Planning/planner_models.py b/PyAres/Planning/planner_models.py index e94f143..fd7ced9 100644 --- a/PyAres/Planning/planner_models.py +++ b/PyAres/Planning/planner_models.py @@ -16,7 +16,7 @@ def __init__(self, planned_value: Any, achieved_value: Any): def __str__(self): return (f"ParameterHistoryItem object with:\n" f" planned_value: {self.planned_value}\n" - f" acheived_value: {self.achieved_value}\n") + f" achieved_value: {self.achieved_value}\n") def __repr__(self) -> str: return self.__str__() @@ -166,7 +166,7 @@ def __init__(self, parameter_names: Optional[list[str]] = None, parameter_values: Optional[list] = None, parameter_data: Optional[dict[str,Any]] = None, - planning_outcome: Outcome = Outcome.SUCCESS, + outcome: Outcome = Outcome.SUCCESS, error_string: str = ""): """ Initializes a PlanResponse. Using either lists of names and values or a python dictonary of name:value pairs @@ -189,7 +189,7 @@ def __init__(self, else: raise ValueError("No values to assign!") - self.outcome = planning_outcome + self.outcome = outcome self.error_string = error_string def __str__(self): diff --git a/PyAres/Planning/planning_service.py b/PyAres/Planning/planning_service.py index 34cbef8..11e938d 100644 --- a/PyAres/Planning/planning_service.py +++ b/PyAres/Planning/planning_service.py @@ -13,6 +13,7 @@ from ares_datamodel import ares_outcome_enum_pb2 from ares_datamodel.connection import connection_state_pb2 from ares_datamodel.connection import connection_info_pb2 +from ares_datamodel import ares_struct_pb2 # Import Utilities from ..Utils import ares_value_utils @@ -22,7 +23,7 @@ from ..Utils import ares_outcome_utils # Import python models -from ..Models import ares_data_models +from ..Models import ares_data_models, Limits from .planner_models import * # Type hint for the user's custom planning logic @@ -38,6 +39,7 @@ def __init__(self, service_name: str, description: str, version: str, timeout: i self._description: str = description self._version: str = version self._settings: Dict[str, ares_data_schema_pb2.AresValueSchema] = {} + self._current_settings: Dict[str, ares_struct_pb2.AresValue] = {} self._planner_options: list[planner_pb2.Planner] = [] self._supported_types: list[ares_data_type_pb2.AresDataType] = [] self._timeout: int = timeout @@ -210,17 +212,35 @@ def add_planner_option(self, planner_name: str, planner_description: str, planne """ self._service_wrapper._planner_options.append(planner_pb2.Planner(planner_name=planner_name, description=planner_description, version=planner_version)) - def add_setting(self, setting_name: str, setting_type: ares_data_models.AresDataType, optional: bool = True, constraints: Union[list[int], list[str], list[float]] = []): + def add_setting(self, setting_name: str, + setting_type: ares_data_models.AresDataType, + default_value: Optional[Any] = None, + optional: Optional[bool] = True, + constraints: Optional[Union[list[int], list[str], list[float]]] = [], + limits: Optional[Limits] = None, + description: Optional[str] = None): """ Adds a planner setting to be reported to ARES when your services capabilities are requested. Args: setting_name (str): The name of the setting. setting_type (AresDataType): The type of this settings value. + default_value: The default value you want to be associated with this setting. Value must match the provided data type, and will be overwritten by ARES if it has another value stored. optional (bool): Whether the setting is optional. constraints: An optional list of values to constrain the available setting choices. Can be integers, strings, or floats. + limits: An optional Limits object for specifying minimum and maximum values + description: An optional string to describe your setting in more detail. Appears in ARES as a tooltip in the settings menu. """ - self._service_wrapper._settings[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type, optional, constraints) + try: + if default_value is not None: + default_ares_value = ares_value_utils.create_ares_value(default_value) + self._service_wrapper._settings[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type, optional, choices=constraints, limits=limits, default_value=default_ares_value, description=description) + + else: + self._service_wrapper._settings[setting_name] = ares_data_schema_utils.create_settings_schema_entry(setting_type, optional, choices=constraints, limits=limits, description=description) + + except Exception as e: + print(f"Encountered an exception while adding setting {setting_name}: {e}") def add_supported_type(self, type: ares_data_models.AresDataType): """ diff --git a/PyAres/Utils/ares_data_schema_utils.py b/PyAres/Utils/ares_data_schema_utils.py index f08a5bd..61e7bfe 100644 --- a/PyAres/Utils/ares_data_schema_utils.py +++ b/PyAres/Utils/ares_data_schema_utils.py @@ -1,10 +1,10 @@ -from typing import Union, Dict, Optional, List -from ares_datamodel import ares_data_schema_pb2 +from typing import Union, Dict, Optional, List, Any +from ares_datamodel import ares_data_schema_pb2, ares_struct_pb2 from ..Models import ares_data_models -from ..Models.ares_data_models import AresSchemaEntry +from ..Models.ares_data_models import AresSchemaEntry, Limits def convert_ares_schema_entry_to_proto(entry: AresSchemaEntry) -> ares_data_schema_pb2.AresValueSchema: - proto_entry = create_settings_schema_entry(entry.type, entry.optional, entry.choices, entry.struct_schema) + proto_entry = create_settings_schema_entry(setting_type=entry.type, optional=entry.optional, choices=entry.choices, struct_schema=entry.struct_schema) proto_entry.description = entry.description if entry.quantity_schema: @@ -20,6 +20,7 @@ def convert_ares_schema_entry_to_proto(entry: AresSchemaEntry) -> ares_data_sche if entry.min_number_value is not None: proto_entry.min_number_value = entry.min_number_value + if entry.max_number_value is not None: proto_entry.max_number_value = entry.max_number_value @@ -28,8 +29,11 @@ def convert_ares_schema_entry_to_proto(entry: AresSchemaEntry) -> ares_data_sche def create_settings_schema_entry( setting_type: ares_data_models.AresDataType, optional: bool, - choices: Union[list[str], list[int], list[float]], - struct_schema: Optional[Dict[str, AresSchemaEntry]] = None) -> ares_data_schema_pb2.AresValueSchema: + default_value: Any = None, + choices: Union[list[str], list[int], list[float]] = None, + struct_schema: Optional[Dict[str, AresSchemaEntry]] = None, + limits: Optional[Limits] = None, + description: Optional[str] = None) -> ares_data_schema_pb2.AresValueSchema: """ Creates a protobuf AresValueSchema message from the provided setting details. @@ -56,4 +60,14 @@ def create_settings_schema_entry( for key, value in struct_schema.items(): schema_entry.struct_schema.fields[key].CopyFrom(convert_ares_schema_entry_to_proto(value)) + if limits is not None: + schema_entry.limits.minimum = limits.minimum + schema_entry.limits.maximum = limits.maximum + + if description is not None: + schema_entry.description = description + + if isinstance(default_value, ares_struct_pb2.AresValue): + schema_entry.default_value.CopyFrom(default_value) + return schema_entry diff --git a/PyAres/Utils/device_status_code_utils.py b/PyAres/Utils/device_status_code_utils.py new file mode 100644 index 0000000..fbcde35 --- /dev/null +++ b/PyAres/Utils/device_status_code_utils.py @@ -0,0 +1,19 @@ +from ..Device import StatusCode +from ares_datamodel import command_status_code_pb2 +from typing import cast + +def python_status_code_to_proto_status_code(py_value: StatusCode) -> command_status_code_pb2.CommandStatusCode: + """ A method to convert from the python StatusCode enum class to the protobuf version """ + val = cast( command_status_code_pb2.CommandStatusCode, py_value.value) + return val + +def proto_status_code_to_python_status_code(proto_value: command_status_code_pb2.CommandStatusCode) -> StatusCode: + """ A method to convert from the protobuf StatusCode enum class to the python version """ + return StatusCode(proto_value) + +def determine_success(code: StatusCode) -> bool: + if code == StatusCode.COMMAND_SUCCESS or code == StatusCode.SUCCESS_WITH_WARNGINGS: + return True + + else: + return False \ No newline at end of file diff --git a/PyAres/__init__.py b/PyAres/__init__.py index 588c9e0..1c51655 100644 --- a/PyAres/__init__.py +++ b/PyAres/__init__.py @@ -4,14 +4,17 @@ from .Planning import PlanningParameter from .Planning import ParameterHistoryItem from .Analyzing import AresAnalyzerService -from .Analyzing import Analysis +from .Analyzing import AnalysisResponse from .Analyzing import AnalysisRequest from .Analyzing import InfoResponse from .Device import AresDeviceService from .Device import DeviceCommandDescriptor from .Device import DeviceSchemaEntry +from .Device import DeviceCommandResponse +from .Device import StatusCode from .Models import AresDataType from .Models import Outcome from .Models import AresSchemaEntry from .Models import Quantity -from .Models import QuantitySchema \ No newline at end of file +from .Models import QuantitySchema +from .Models import Limits \ No newline at end of file diff --git a/README.md b/README.md index 296ce11..ca943f0 100644 --- a/README.md +++ b/README.md @@ -59,10 +59,10 @@ Analyzers can be initialized using the AresAnalyzerService class. Below is a bas ```Python from PyAres import AresAnalyzerService from PyAres import AnalysisRequest -from PyAres import Analysis +from PyAres import AnalysisResponse from PyAres import AresDataType -def analyze(request: AnalysisRequest) -> Analysis: +def analyze(request: AnalysisRequest) -> AnalysisResponse: #Custom Analysis Logic growth = request.inputs.get("Growth") temperature = request.inputs.get("Temperature") @@ -70,7 +70,7 @@ def analyze(request: AnalysisRequest) -> Analysis: print(f"Growth: {growth}") print(f"Temperature: {temperature}") - analysis = Analysis(result=growth, success=True) + analysis = AnalysisResponse(result=growth, success=True) return analysis diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py index b2824d9..b27916c 100644 --- a/tests/test_analyzer.py +++ b/tests/test_analyzer.py @@ -1,7 +1,7 @@ import unittest from PyAres import AresAnalyzerService, Outcome from PyAres.Models import ares_data_models -from PyAres.Analyzing.analyzer_models import AnalysisRequest, Analysis +from PyAres.Analyzing.analyzer_models import AnalysisRequest, AnalysisResponse from ares_datamodel.analyzing.remote import ares_remote_analyzer_service_pb2 as analyzer_service from ares_datamodel.analyzing import analysis_pb2 from ares_datamodel import ares_data_type_pb2, ares_outcome_enum_pb2, ares_data_schema_pb2 @@ -28,11 +28,11 @@ class TestAresAnalyzerService(unittest.TestCase): def setUp(self): self.captured_request: AnalysisRequest | None = None - def dummy_analyze(request: AnalysisRequest) -> Analysis: + def dummy_analyze(request: AnalysisRequest) -> AnalysisResponse: self.captured_request = request # Return a valid Analysis object - return Analysis( + return AnalysisResponse( result=100.0, outcome=Outcome.SUCCESS )