From aa895e6a589547e77cd845912ed0e08287eb48b3 Mon Sep 17 00:00:00 2001 From: Arnas Babeckis Date: Sun, 10 May 2026 21:07:27 -0400 Subject: [PATCH 1/4] Added support for the newly added int, float, and timestamp. --- PyAres/Models/ares_data_models.py | 5 +- PyAres/Utils/ares_data_type_utils.py | 3 + PyAres/Utils/ares_struct_utils.py | 65 ++++++++++++++++-- PyAres/Utils/ares_value_utils.py | 80 +++++++++++++++++++++-- pyproject.toml | 4 +- tests/test_ares_data_schema_utils.py | 15 ++++- tests/test_ares_data_type_utils.py | 14 +++- tests/test_ares_struct_utils.py | 20 +++++- tests/test_ares_value_type_conversions.py | 29 +++++++- 9 files changed, 216 insertions(+), 19 deletions(-) diff --git a/PyAres/Models/ares_data_models.py b/PyAres/Models/ares_data_models.py index 1a3a2fa..0d34a51 100644 --- a/PyAres/Models/ares_data_models.py +++ b/PyAres/Models/ares_data_models.py @@ -19,6 +19,9 @@ class AresDataType(Enum): UNIT = 11 FUNCTION = 12 QUANTITY = 13 + TIMESTAMP = 14 + FLOAT = 15 + INT = 16 class Outcome(Enum): UNSPECIFIED_OUTCOME = 0 @@ -76,4 +79,4 @@ class AresSchemaEntry: struct_schema: Optional[Dict[str, 'AresSchemaEntry']] = None list_element_schema: Optional['AresSchemaEntry'] = None min_number_value: Optional[float] = None - max_number_value: Optional[float] = None \ No newline at end of file + max_number_value: Optional[float] = None diff --git a/PyAres/Utils/ares_data_type_utils.py b/PyAres/Utils/ares_data_type_utils.py index 3275107..e9e3d4e 100644 --- a/PyAres/Utils/ares_data_type_utils.py +++ b/PyAres/Utils/ares_data_type_utils.py @@ -1,6 +1,7 @@ from typing import Union, cast from ares_datamodel import ares_data_type_pb2 from ..Models import AresDataType +from datetime import datetime def python_ares_type_to_proto_ares_type(py_value: AresDataType) -> ares_data_type_pb2.AresDataType: """ A method to convert from the python AresDataType class to the protobuf version """ @@ -22,6 +23,8 @@ def determine_python_ares_data_type(value: Union[int, float, str, bool, list, di return AresDataType.NUMBER case float(): return AresDataType.NUMBER + case datetime(): + return AresDataType.TIMESTAMP case bytes(): return AresDataType.BYTE_ARRAY case dict(): diff --git a/PyAres/Utils/ares_struct_utils.py b/PyAres/Utils/ares_struct_utils.py index 0027216..c8a7c8a 100644 --- a/PyAres/Utils/ares_struct_utils.py +++ b/PyAres/Utils/ares_struct_utils.py @@ -1,4 +1,6 @@ from ares_datamodel import ares_struct_pb2 +from datetime import datetime +from google.protobuf import timestamp_pb2 from typing import Union, Dict, Any from . import ares_value_utils @@ -53,6 +55,54 @@ def create_number_struct(key: str, value: Union[int, float]) -> ares_struct_pb2. new_entry.CopyFrom(ares_value_utils.create_number(value)) return new_struct +def create_timestamp_struct(key: str, value: Union[datetime, timestamp_pb2.Timestamp]) -> ares_struct_pb2.AresStruct: + """ + Creates a new AresStruct with the provided timestamp and key. + + Args: + key (str): The associated key to be used when storing the given value. + value (Union[datetime, Timestamp]): The timestamp to be stored in the new AresStruct. + + Returns: + (AresStruct): A new AresStruct containing the provided key and value. + """ + new_struct = ares_struct_pb2.AresStruct() + new_entry = new_struct.fields[key] + new_entry.CopyFrom(ares_value_utils.create_timestamp(value)) + return new_struct + +def create_float_struct(key: str, value: float) -> ares_struct_pb2.AresStruct: + """ + Creates a new AresStruct with the provided float and key. + + Args: + key (str): The associated key to be used when storing the given value. + value (float): The double-backed float to be stored in the new AresStruct. + + Returns: + (AresStruct): A new AresStruct containing the provided key and value. + """ + new_struct = ares_struct_pb2.AresStruct() + new_entry = new_struct.fields[key] + new_entry.CopyFrom(ares_value_utils.create_float(value)) + return new_struct + +def create_int_struct(key: str, value: int) -> ares_struct_pb2.AresStruct: + """ + Creates a new AresStruct with the provided int and key. + + Args: + key (str): The associated key to be used when storing the given value. + value (int): The integer to be stored in the new AresStruct. + + Returns: + (AresStruct): A new AresStruct containing the provided key and value. + """ + new_struct = ares_struct_pb2.AresStruct() + new_entry = new_struct.fields[key] + new_entry.CopyFrom(ares_value_utils.create_int(value)) + return new_struct + def create_bool_struct(key: str, value: bool) -> ares_struct_pb2.AresStruct: """ Creates a new AresStruct with the provided bool and key. @@ -141,7 +191,7 @@ def add_value_to_struct(existing_struct: ares_struct_pb2.AresStruct, key: str, n key (str): The key to be associated with the new value. new_value (ares_struct_pb2.AresValue): The AresValue that will be added to the provided struct. replace (bool): An optional boolean value that determines whether to overwrite any existing values in your struct. - + Returns: (AresStruct): The provided struct with the new value appended. """ @@ -186,13 +236,16 @@ def create_ares_struct(key: str, value: Any) -> ares_struct_pb2.AresStruct: if(isinstance(value, str)): return create_string_struct(key, value) - - elif(isinstance(value, (int, float))): - return create_number_struct(key, value) - + elif(isinstance(value, bool)): return create_bool_struct(key, value) - + + elif(isinstance(value, (int, float))): + return create_number_struct(key, value) + + elif(isinstance(value, (datetime, timestamp_pb2.Timestamp))): + return create_timestamp_struct(key, value) + elif(isinstance(value, bytes)): return create_bytes_array_struct(key, value) diff --git a/PyAres/Utils/ares_value_utils.py b/PyAres/Utils/ares_value_utils.py index 88ab15d..0af2c75 100644 --- a/PyAres/Utils/ares_value_utils.py +++ b/PyAres/Utils/ares_value_utils.py @@ -1,5 +1,7 @@ from ares_datamodel import ares_struct_pb2 from ares_datamodel import ares_data_type_pb2 +from datetime import datetime, timezone +from google.protobuf import timestamp_pb2 from typing import Union, Any, Dict from . import ares_data_type_utils @@ -12,6 +14,12 @@ def ares_value_to_py(ares_value: ares_struct_pb2.AresValue): return None elif field == "number_value": return ares_value.number_value + elif field == "timestamp_value": + return ares_value.timestamp_value.ToDatetime(tzinfo=timezone.utc) + elif field == "float_value": + return ares_value.float_value + elif field == "int_value": + return ares_value.int_value elif field == "string_value": return ares_value.string_value elif field == "bool_value": @@ -40,6 +48,8 @@ def py_to_ares_value(py_value, ares_value: ares_struct_pb2.AresValue): ares_value.bool_value = py_value elif isinstance(py_value, (int, float)): ares_value.number_value = py_value + elif isinstance(py_value, (datetime, timestamp_pb2.Timestamp)): + ares_value.timestamp_value.CopyFrom(_to_timestamp(py_value)) elif isinstance(py_value, bytes): ares_value.bytes_value = py_value elif isinstance(py_value, Quantity): @@ -52,6 +62,9 @@ def py_to_ares_value(py_value, ares_value: ares_struct_pb2.AresValue): elif isinstance(py_value, list): if(all(isinstance(x, str) for x in py_value)): ares_value.string_array_value.strings.extend(py_value) + elif(all(isinstance(x, bool) for x in py_value)): + for item in py_value: + ares_value.list_value.values.append(create_ares_value(item)) elif(all(isinstance(x, (int, float)) for x in py_value)): ares_value.number_array_value.numbers.extend(py_value) else: @@ -84,6 +97,52 @@ def create_number(value: Union[int, float]) -> ares_struct_pb2.AresValue: """ return ares_struct_pb2.AresValue(number_value=value) +def _to_timestamp(value: Union[datetime, timestamp_pb2.Timestamp]) -> timestamp_pb2.Timestamp: + if isinstance(value, timestamp_pb2.Timestamp): + return value + + timestamp = timestamp_pb2.Timestamp() + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + timestamp.FromDatetime(value.astimezone(timezone.utc)) + return timestamp + +def create_timestamp(value: Union[datetime, timestamp_pb2.Timestamp]) -> ares_struct_pb2.AresValue: + """ + Creates a new AresValue, initialized to the provided timestamp value. + + Args: + value (Union[datetime, Timestamp]): The timestamp to be stored in the new AresValue. + + Returns: + (AresValue): A new AresValue containing the provided timestamp value. + """ + return ares_struct_pb2.AresValue(timestamp_value=_to_timestamp(value)) + +def create_float(value: float) -> ares_struct_pb2.AresValue: + """ + Creates a new AresValue, initialized to the provided float value. + + Args: + value (float): The double-backed float to be stored in the new AresValue. + + Returns: + (AresValue): A new AresValue containing the provided float value. + """ + return ares_struct_pb2.AresValue(float_value=float(value)) + +def create_int(value: int) -> ares_struct_pb2.AresValue: + """ + Creates a new AresValue, initialized to the provided int value. + + Args: + value (int): The integer to be stored in the new AresValue. + + Returns: + (AresValue): A new AresValue containing the provided int value. + """ + return ares_struct_pb2.AresValue(int_value=int(value)) + def create_string(value: str) -> ares_struct_pb2.AresValue: """ Creates a new AresValue, initialized to the provided string value. @@ -182,7 +241,7 @@ def create_struct(values: Dict) -> ares_struct_pb2.AresValue: (AresValue): A new AresValue containing the provided dictionaries values in an AresStruct. """ ares_value = ares_struct_pb2.AresValue() - + for key, value in values.items(): new_entry = ares_value.struct_value.fields[key] new_entry.CopyFrom(create_ares_value(value)) @@ -207,6 +266,12 @@ def create_default(python_datatype: AresDataType) -> ares_struct_pb2.AresValue: elif(dataType == ares_data_type_pb2.AresDataType.NUMBER): return create_number(0) + elif(dataType == ares_data_type_pb2.AresDataType.TIMESTAMP): + return create_timestamp(datetime.fromtimestamp(0, tz=timezone.utc)) + elif(dataType == ares_data_type_pb2.AresDataType.FLOAT): + return create_float(0.0) + elif(dataType == ares_data_type_pb2.AresDataType.INT): + return create_int(0) elif(dataType == ares_data_type_pb2.AresDataType.STRING): return create_string("") @@ -246,16 +311,19 @@ def create_ares_value(value: Any) -> ares_struct_pb2.AresValue: """ if(isinstance(value, str)): return create_string(value) - + elif(isinstance(value, bool)): return create_bool(value) elif(isinstance(value, (int, float))): return create_number(value) - + + elif(isinstance(value, (datetime, timestamp_pb2.Timestamp))): + return create_timestamp(value) + elif(isinstance(value, bytes)): return create_bytes(value) - + elif(isinstance(value, Quantity)): return create_quantity(value) @@ -272,8 +340,8 @@ def create_ares_value(value: Any) -> ares_struct_pb2.AresValue: else: return create_array(value) - elif(isinstance(value, Dict)): + elif(isinstance(value, dict)): return create_struct(value) else: - return create_null() \ No newline at end of file + return create_null() diff --git a/pyproject.toml b/pyproject.toml index 73f3010..570d785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ ] dependencies = [ - "ares-datamodel >= 0.20.1", + "ares-datamodel >= 0.21.1", ] [project.urls] @@ -27,4 +27,4 @@ source = "env" variable = "PYARES_PACKAGE_VERSION" [tool.hatch.build.targets.wheel] -packages = ["PyAres"] \ No newline at end of file +packages = ["PyAres"] diff --git a/tests/test_ares_data_schema_utils.py b/tests/test_ares_data_schema_utils.py index 1eb0810..19d482c 100644 --- a/tests/test_ares_data_schema_utils.py +++ b/tests/test_ares_data_schema_utils.py @@ -31,6 +31,19 @@ def test_create_entry_with_number_choices(self): ) self.assertEqual(entry.number_choices.numbers, choices) + def test_create_entries_for_new_scalar_types(self): + for py_type, proto_type in [ + (AresDataType.TIMESTAMP, ares_data_type_pb2.AresDataType.TIMESTAMP), + (AresDataType.FLOAT, ares_data_type_pb2.AresDataType.FLOAT), + (AresDataType.INT, ares_data_type_pb2.AresDataType.INT), + ]: + entry = ares_data_schema_utils.create_settings_schema_entry( + py_type, + optional=False, + choices=[] + ) + self.assertEqual(entry.type, proto_type) + def test_create_entry_with_mixed_choices(self): # Should result in no choices being set if types are mixed choices = ["A", 1] @@ -72,4 +85,4 @@ def test_nested_struct_schema(self): self.assertEqual(inner_proto.description, "Inner") if __name__ == '__main__': - unittest.main(verbosity=2) \ No newline at end of file + unittest.main(verbosity=2) diff --git a/tests/test_ares_data_type_utils.py b/tests/test_ares_data_type_utils.py index 0fb6bc1..151a003 100644 --- a/tests/test_ares_data_type_utils.py +++ b/tests/test_ares_data_type_utils.py @@ -1,6 +1,8 @@ import unittest from PyAres.Utils import ares_data_type_utils from PyAres.Models import AresDataType +from ares_datamodel import ares_data_type_pb2 +from datetime import datetime, timezone class TestAresDataTypeUtils(unittest.TestCase): def test_determine_python_ares_data_type(self): @@ -8,11 +10,21 @@ def test_determine_python_ares_data_type(self): self.assertEqual(ares_data_type_utils.determine_python_ares_data_type(True), AresDataType.BOOLEAN) self.assertEqual(ares_data_type_utils.determine_python_ares_data_type(123), AresDataType.NUMBER) self.assertEqual(ares_data_type_utils.determine_python_ares_data_type(12.34), AresDataType.NUMBER) + self.assertEqual(ares_data_type_utils.determine_python_ares_data_type(datetime.now(timezone.utc)), AresDataType.TIMESTAMP) self.assertEqual(ares_data_type_utils.determine_python_ares_data_type(["a", "b"]), AresDataType.STRING_ARRAY) self.assertEqual(ares_data_type_utils.determine_python_ares_data_type([1, 2.0]), AresDataType.NUMBER_ARRAY) self.assertEqual(ares_data_type_utils.determine_python_ares_data_type([True, False]), AresDataType.LIST) self.assertEqual(ares_data_type_utils.determine_python_ares_data_type([1, "a"]), AresDataType.LIST) + def test_new_scalar_type_round_trips(self): + for py_type, proto_type in [ + (AresDataType.TIMESTAMP, ares_data_type_pb2.AresDataType.TIMESTAMP), + (AresDataType.FLOAT, ares_data_type_pb2.AresDataType.FLOAT), + (AresDataType.INT, ares_data_type_pb2.AresDataType.INT), + ]: + self.assertEqual(ares_data_type_utils.python_ares_type_to_proto_ares_type(py_type), proto_type) + self.assertEqual(ares_data_type_utils.proto_ares_type_to_python_ares_type(proto_type), py_type) + if __name__ == '__main__': - unittest.main(verbosity=2) \ No newline at end of file + unittest.main(verbosity=2) diff --git a/tests/test_ares_struct_utils.py b/tests/test_ares_struct_utils.py index 41b9736..ca0fa63 100644 --- a/tests/test_ares_struct_utils.py +++ b/tests/test_ares_struct_utils.py @@ -1,6 +1,7 @@ import unittest from PyAres.Utils import ares_struct_utils from ares_datamodel import ares_struct_pb2 +from datetime import datetime, timezone class TestAresStructUtils(unittest.TestCase): def test_create_string_struct(self): @@ -11,6 +12,20 @@ def test_create_number_struct(self): s = ares_struct_utils.create_number_struct("key", 123) self.assertEqual(s.fields["key"].number_value, 123) + def test_create_new_scalar_structs(self): + timestamp = datetime(2026, 5, 10, 12, 30, tzinfo=timezone.utc) + + s_timestamp = ares_struct_utils.create_timestamp_struct("key", timestamp) + self.assertEqual(s_timestamp.fields["key"].WhichOneof("kind"), "timestamp_value") + + s_float = ares_struct_utils.create_float_struct("key", 1.25) + self.assertEqual(s_float.fields["key"].WhichOneof("kind"), "float_value") + self.assertEqual(s_float.fields["key"].float_value, 1.25) + + s_int = ares_struct_utils.create_int_struct("key", 42) + self.assertEqual(s_int.fields["key"].WhichOneof("kind"), "int_value") + self.assertEqual(s_int.fields["key"].int_value, 42) + def test_create_bool_struct(self): s = ares_struct_utils.create_bool_struct("key", True) self.assertEqual(s.fields["key"].bool_value, True) @@ -45,6 +60,9 @@ def test_create_ares_struct_inference(self): s_bytes = ares_struct_utils.create_ares_struct("k", b"123") self.assertEqual(s_bytes.fields["k"].bytes_value, b"123") + s_timestamp = ares_struct_utils.create_ares_struct("k", datetime(2026, 5, 10, 12, 30, tzinfo=timezone.utc)) + self.assertEqual(s_timestamp.fields["k"].WhichOneof("kind"), "timestamp_value") + s_list_str = ares_struct_utils.create_ares_struct("k", ["a", "b"]) self.assertEqual(s_list_str.fields["k"].string_array_value.strings, ["a", "b"]) @@ -78,4 +96,4 @@ def test_dict_conversions(self): self.assertEqual(new_dict["list"], [1, 2]) if __name__ == '__main__': - unittest.main(verbosity=2) \ No newline at end of file + unittest.main(verbosity=2) diff --git a/tests/test_ares_value_type_conversions.py b/tests/test_ares_value_type_conversions.py index 390f6af..7a3d871 100644 --- a/tests/test_ares_value_type_conversions.py +++ b/tests/test_ares_value_type_conversions.py @@ -1,5 +1,7 @@ import unittest from PyAres.Utils import ares_value_utils +from PyAres.Models import AresDataType +from datetime import datetime, timezone class TestAresValueTypeConversions(unittest.TestCase): def test_float_ares_value(self): @@ -12,6 +14,31 @@ def test_int_ares_value(self): int_value = ares_value_utils.create_ares_value(original_int) self.assertEqual(int_value.number_value, original_int) + def test_explicit_float_ares_value(self): + original_float = 1.25 + float_value = ares_value_utils.create_float(original_float) + self.assertEqual(float_value.WhichOneof("kind"), "float_value") + self.assertEqual(float_value.float_value, original_float) + self.assertEqual(ares_value_utils.ares_value_to_py(float_value), original_float) + + def test_explicit_int_ares_value(self): + original_int = 42 + int_value = ares_value_utils.create_int(original_int) + self.assertEqual(int_value.WhichOneof("kind"), "int_value") + self.assertEqual(int_value.int_value, original_int) + self.assertEqual(ares_value_utils.ares_value_to_py(int_value), original_int) + + def test_timestamp_ares_value(self): + original_timestamp = datetime(2026, 5, 10, 12, 30, tzinfo=timezone.utc) + timestamp_value = ares_value_utils.create_timestamp(original_timestamp) + self.assertEqual(timestamp_value.WhichOneof("kind"), "timestamp_value") + self.assertEqual(ares_value_utils.ares_value_to_py(timestamp_value), original_timestamp) + + def test_new_scalar_defaults(self): + self.assertEqual(ares_value_utils.create_default(AresDataType.TIMESTAMP).WhichOneof("kind"), "timestamp_value") + self.assertEqual(ares_value_utils.create_default(AresDataType.FLOAT).WhichOneof("kind"), "float_value") + self.assertEqual(ares_value_utils.create_default(AresDataType.INT).WhichOneof("kind"), "int_value") + def test_negative_ares_value(self): original_num = -1.0 negative_value = ares_value_utils.create_ares_value(original_num) @@ -72,4 +99,4 @@ def test_struct_ares_value(self): self.assertEqual(struct_value.struct_value.fields["nested"].struct_value.fields["inner"].number_value, 123.0) if __name__ == '__main__': - unittest.main(verbosity=2) \ No newline at end of file + unittest.main(verbosity=2) From b092c7d3df3d3c503393803638d878a4774c8136 Mon Sep 17 00:00:00 2001 From: Arnas Babeckis Date: Sun, 17 May 2026 15:07:45 -0400 Subject: [PATCH 2/4] Updated datamodel version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 570d785..5f766d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ ] dependencies = [ - "ares-datamodel >= 0.21.1", + "ares-datamodel >= 0.22.0", ] [project.urls] From 245a85d950befa11ea354e5e3a2fcc68b388cdec Mon Sep 17 00:00:00 2001 From: "Babeckis, Arnas" Date: Thu, 28 May 2026 13:39:57 -0400 Subject: [PATCH 3/4] Fixed schema for hotplate. Added a random number gen device. --- PyAres/Demo/hotplate.py | 16 +++++-- PyAres/Demo/random_number_device.py | 69 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 PyAres/Demo/random_number_device.py diff --git a/PyAres/Demo/hotplate.py b/PyAres/Demo/hotplate.py index ee56da6..42901d2 100644 --- a/PyAres/Demo/hotplate.py +++ b/PyAres/Demo/hotplate.py @@ -54,9 +54,19 @@ def safe_mode(self): service.add_new_command(set_cmd, my_hotplate.set_temperature) # 4. Define Command: Get Temperature - # This schema tells ARES to expect a number back + # This schema tells ARES to expect a struct back output_schema = { - "current_temp": DeviceSchemaEntry(AresDataType.NUMBER, "Current Temperature", "Celsius") + "output": DeviceSchemaEntry( + AresDataType.STRUCT, + "Current temperature output", + struct_schema={ + "current_temp": DeviceSchemaEntry( + AresDataType.NUMBER, + "Current Temperature", + "Celsius" + ) + } + ) } get_cmd = DeviceCommandDescriptor( "Get Temp", @@ -69,4 +79,4 @@ def safe_mode(self): # 5. Start the Service # This will block and listen for ARES connections print("Virtual Hotplate Service Running...") - service.start() \ No newline at end of file + service.start() diff --git a/PyAres/Demo/random_number_device.py b/PyAres/Demo/random_number_device.py new file mode 100644 index 0000000..d460026 --- /dev/null +++ b/PyAres/Demo/random_number_device.py @@ -0,0 +1,69 @@ +import random + +from PyAres import AresDeviceService, AresDataType, DeviceSchemaEntry, DeviceCommandDescriptor + + +# --- PART 1: The Simulated Hardware --- +class VirtualRandomNumberDevice: + def __init__(self): + self.last_number = 0 + + 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} + + def get_state(self): + """Required: Tells ARES the current status for logging.""" + return {"last_number": self.last_number} + + def safe_mode(self): + """Required: A safety fallback.""" + print("[Hardware] SAFE MODE TRIGGERED: Random number device idle.") + + +# --- PART 2: The Ares Service Wrapper --- +if __name__ == "__main__": + # 1. Initialize the hardware + my_random_device = VirtualRandomNumberDevice() + + # 2. Define the Service Info + service = AresDeviceService( + my_random_device.safe_mode, + my_random_device.get_state, + "My Virtual Random Number Device", + "A simulated device that generates random numbers", + "1.0.0", + port=7101 + ) + + # 3. Define Command: Generate Number + # This schema tells ARES to expect a struct back + output_schema = { + "output": DeviceSchemaEntry( + AresDataType.STRUCT, + "Generated random number output", + struct_schema={ + "random_number": DeviceSchemaEntry( + AresDataType.NUMBER, + "Random Number", + "1-100", + min_number_value=1, + max_number_value=100, + ) + }, + ) + } + generate_cmd = DeviceCommandDescriptor( + "Generate Number", + "Generates a random number from 1 to 100", + {}, + output_schema, + ) + service.add_new_command(generate_cmd, my_random_device.generate_number) + + # 4. Start the Service + # This will block and listen for ARES connections + print("Virtual Random Number Device Service Running...") + service.start() From 53407e3be34a5a8313b2ec5b9fdc4dac726f7c83 Mon Sep 17 00:00:00 2001 From: "Babeckis, Arnas" Date: Thu, 4 Jun 2026 14:29:26 -0400 Subject: [PATCH 4/4] Better handle command failure. Added a device that fails on purpose. --- PyAres/Demo/test_device.py | 66 +++++++++++++++++++++++++++++++++ PyAres/Device/device_service.py | 7 +++- tests/test_ares_device.py | 18 ++++++++- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 PyAres/Demo/test_device.py diff --git a/PyAres/Demo/test_device.py b/PyAres/Demo/test_device.py new file mode 100644 index 0000000..4a6b4e5 --- /dev/null +++ b/PyAres/Demo/test_device.py @@ -0,0 +1,66 @@ +import random + +from PyAres import AresDeviceService, AresDataType, DeviceCommandDescriptor, DeviceSchemaEntry + + +class TestDevice: + def fail(self): + """Intentionally fails so command failure handling can be tested.""" + print("[Test Device] Running intentionally failing command...") + raise RuntimeError("Intentional test command failure") + + def maybe_fail(self): + """Fails half the time and otherwise returns 20.""" + print("[Test Device] Running command with a 50% failure chance...") + if random.random() < 0.5: + raise RuntimeError("Random test command failure") + + print("[Test Device] Command succeeded and returned 20.") + return {"number": 20} + + def get_state(self): + return {} + + def safe_mode(self): + print("[Test Device] Safe mode triggered.") + + +if __name__ == "__main__": + test_device = TestDevice() + + service = AresDeviceService( + test_device.safe_mode, + test_device.get_state, + "Test Device", + "A device with commands for testing ARES failure handling", + "1.0.0", + port=7102, + ) + + fail_command = DeviceCommandDescriptor( + "Fail", + "Intentionally throws an exception", + {}, + {}, + ) + service.add_new_command(fail_command, test_device.fail) + + maybe_fail_output_schema = { + "output": DeviceSchemaEntry( + AresDataType.STRUCT, + "Successful command output", + struct_schema={ + "number": DeviceSchemaEntry(AresDataType.NUMBER, "Returned Number") + }, + ) + } + maybe_fail_command = DeviceCommandDescriptor( + "Maybe Fail", + "Has a 50% chance of failing; otherwise returns 20", + {}, + maybe_fail_output_schema, + ) + service.add_new_command(maybe_fail_command, test_device.maybe_fail) + + print("Test Device Service Running...") + service.start() diff --git a/PyAres/Device/device_service.py b/PyAres/Device/device_service.py index eb9227c..ac6ace1 100644 --- a/PyAres/Device/device_service.py +++ b/PyAres/Device/device_service.py @@ -83,7 +83,12 @@ def ExecuteCommand(self, request: device_service.ExecuteCommandRequest, context) #Convert the protobuf map to a Python dictionary provided_param_dict = ares_struct_utils.ares_struct_to_dict(request.arguments) - result : Dict[str, Any] = method(**provided_param_dict) + 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(): diff --git a/tests/test_ares_device.py b/tests/test_ares_device.py index b70cd27..6125c0d 100644 --- a/tests/test_ares_device.py +++ b/tests/test_ares_device.py @@ -136,6 +136,22 @@ def simple_cmd(arg1): return {} self.assertFalse(resp_mis.success) self.assertIn("parameter count did not match", resp_mis.error) + def test_command_exception_returns_failed_result(self): + """Test that command exceptions do not escape the service.""" + self.service = AresDeviceService(self.enter_safe_mode_func, self.get_state_func, self.device_name, self.device_desc, self.device_version, port=0) + + def fail(): + raise RuntimeError("Intentional failure") + + desc = DeviceCommandDescriptor("Fail", "Intentionally fails", {}, {}) + self.service.add_new_command(desc, fail) + + req = device_service.ExecuteCommandRequest(command_name="Fail") + response = self.service._service_wrapper.ExecuteCommand(req, None) + + self.assertFalse(response.success) + self.assertIn("Intentional failure", response.error) + def test_state_streaming(self): """Test the generator function for state streaming.""" self.service = AresDeviceService(self.enter_safe_mode_func, self.get_state_func, self.device_name, self.device_desc, self.device_version, port=0) @@ -167,4 +183,4 @@ def test_safe_mode(self): self.assertTrue(self.safe_mode_called, "EnterSafeMode did not trigger the user callback") if __name__ == '__main__': - unittest.main(verbosity=2) \ No newline at end of file + unittest.main(verbosity=2)