diff --git a/.gitignore b/.gitignore index 52e89f5..ea9338f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea .vscode +.venv _*/ cmake-build-debug/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index dbccd66..64c6843 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,7 @@ INCLUDE(CheckPIESupported) CHECK_PIE_SUPPORTED() SET(CMAKE_POSITION_INDEPENDENT_CODE ON) -SET(MISSION_MODULE_VERSION 1.2.12) +SET(MISSION_MODULE_VERSION 1.2.13) OPTION(BRINGAUTO_INSTALL "Configure install" OFF) OPTION(BRINGAUTO_PACKAGE "Configure package creation" OFF) diff --git a/README.md b/README.md index 1f9bca1..3fec930 100644 --- a/README.md +++ b/README.md @@ -56,3 +56,7 @@ External Server Module Configuration is required as: - `api_key`: generated in Fleet Protocol HTTP API (script/new_admin.py) - `company_name`, `car_name`: used to identify the car in Fleet Protocol HTTP API - `max_requests_threshold_count`, `max_requests_threshold_period_ms`, `delay_after_threshold_reached_ms`, `retry_requests_delay_ms`: explained in [HTTP client README](./lib/fleet-v2-http-client/README.md) + +## Testing + +All tests are contained within the `tests` folder. [Testing README](./tests/README.md) diff --git a/docs/mission_module.md b/docs/mission_module.md index d94cd00..2a788a2 100644 --- a/docs/mission_module.md +++ b/docs/mission_module.md @@ -19,6 +19,21 @@ The Autonomy keeps in memory the NAME of the next stop (the `nextStop` in the ac The Autonomy device sends the car status to the Mission Module. The status contains a field `State` with the value corresponding to the state of the device (`DRIVE`, `IN_STOP`, `IDLE`, `OBSTACLE`, `ERROR`). +When the connection between module gateway and external server is dropped, stops are being removed from commands when certain conditions are met. When a stop is finished while offline, it is added to a list of stops in the error message, which will be sent on reconnection. + +```mermaid +flowchart TD + command_gen(Command generation) + command_gen --> stop_size{{Any remaining stops in command?}} + stop_size -->|yes| in_stop{{IN_STOP car state in current status?}} + in_stop -->|yes| remove_check{{DRIVE car state in previous satus OR next station in command equal to next station in current status}} + remove_check -->|yes| remove_stop(Remove next stop from command) + error_aggr(Error aggregation) + error_aggr --> in_stop_2{{IN_STOP car state in current status?}} + in_stop_2 -->|yes| same_stop{{Last finished stop in error message different than current stop?}} + same_stop -->|yes| add_stop(Add current stop to finished stops in error message) +``` + # Messages ## Structure diff --git a/lib/protobuf-mission-module/.gitignore b/lib/protobuf-mission-module/.gitignore new file mode 100644 index 0000000..703e159 --- /dev/null +++ b/lib/protobuf-mission-module/.gitignore @@ -0,0 +1,2 @@ +build +*.egg-info \ No newline at end of file diff --git a/lib/protobuf-mission-module/mission_module_protobuf_files/MissionModule_pb2.py b/lib/protobuf-mission-module/mission_module_protobuf_files/MissionModule_pb2.py new file mode 100644 index 0000000..8962fad --- /dev/null +++ b/lib/protobuf-mission-module/mission_module_protobuf_files/MissionModule_pb2.py @@ -0,0 +1,424 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: MissionModule.proto + +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='MissionModule.proto', + package='MissionModule', + syntax='proto3', + serialized_options=b'Z!../internal/pkg/ba_proto;ba_proto\252\002\030Google.Protobuf.ba_proto', + create_key=_descriptor._internal_create_key, + serialized_pb=b'\n\x13MissionModule.proto\x12\rMissionModule\"\xd5\x02\n\x0e\x41utonomyStatus\x12:\n\ttelemetry\x18\x01 \x01(\x0b\x32\'.MissionModule.AutonomyStatus.Telemetry\x12\x32\n\x05state\x18\x02 \x01(\x0e\x32#.MissionModule.AutonomyStatus.State\x12-\n\x08nextStop\x18\x03 \x01(\x0b\x32\x16.MissionModule.StationH\x00\x88\x01\x01\x1aS\n\tTelemetry\x12\r\n\x05speed\x18\x01 \x01(\x01\x12\x0c\n\x04\x66uel\x18\x02 \x01(\x01\x12)\n\x08position\x18\x03 \x01(\x0b\x32\x17.MissionModule.Position\"B\n\x05State\x12\x08\n\x04IDLE\x10\x00\x12\t\n\x05\x44RIVE\x10\x01\x12\x0b\n\x07IN_STOP\x10\x02\x12\x0c\n\x08OBSTACLE\x10\x03\x12\t\n\x05\x45RROR\x10\x04\x42\x0b\n\t_nextStop\"\xac\x01\n\x0f\x41utonomyCommand\x12%\n\x05stops\x18\x01 \x03(\x0b\x32\x16.MissionModule.Station\x12\r\n\x05route\x18\x02 \x01(\t\x12\x35\n\x06\x61\x63tion\x18\x03 \x01(\x0e\x32%.MissionModule.AutonomyCommand.Action\",\n\x06\x41\x63tion\x12\r\n\tNO_ACTION\x10\x00\x12\x08\n\x04STOP\x10\x01\x12\t\n\x05START\x10\x02\">\n\rAutonomyError\x12-\n\rfinishedStops\x18\x01 \x03(\x0b\x32\x16.MissionModule.Station\"B\n\x07Station\x12\x0c\n\x04name\x18\x01 \x01(\t\x12)\n\x08position\x18\x02 \x01(\x0b\x32\x17.MissionModule.Position\"A\n\x08Position\x12\x10\n\x08latitude\x18\x01 \x01(\x01\x12\x11\n\tlongitude\x18\x02 \x01(\x01\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x01\x42>Z!../internal/pkg/ba_proto;ba_proto\xaa\x02\x18Google.Protobuf.ba_protob\x06proto3' +) + + + +_AUTONOMYSTATUS_STATE = _descriptor.EnumDescriptor( + name='State', + full_name='MissionModule.AutonomyStatus.State', + filename=None, + file=DESCRIPTOR, + create_key=_descriptor._internal_create_key, + values=[ + _descriptor.EnumValueDescriptor( + name='IDLE', index=0, number=0, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='DRIVE', index=1, number=1, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='IN_STOP', index=2, number=2, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='OBSTACLE', index=3, number=3, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='ERROR', index=4, number=4, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + ], + containing_type=None, + serialized_options=None, + serialized_start=301, + serialized_end=367, +) +_sym_db.RegisterEnumDescriptor(_AUTONOMYSTATUS_STATE) + +_AUTONOMYCOMMAND_ACTION = _descriptor.EnumDescriptor( + name='Action', + full_name='MissionModule.AutonomyCommand.Action', + filename=None, + file=DESCRIPTOR, + create_key=_descriptor._internal_create_key, + values=[ + _descriptor.EnumValueDescriptor( + name='NO_ACTION', index=0, number=0, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='STOP', index=1, number=1, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + _descriptor.EnumValueDescriptor( + name='START', index=2, number=2, + serialized_options=None, + type=None, + create_key=_descriptor._internal_create_key), + ], + containing_type=None, + serialized_options=None, + serialized_start=511, + serialized_end=555, +) +_sym_db.RegisterEnumDescriptor(_AUTONOMYCOMMAND_ACTION) + + +_AUTONOMYSTATUS_TELEMETRY = _descriptor.Descriptor( + name='Telemetry', + full_name='MissionModule.AutonomyStatus.Telemetry', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='speed', full_name='MissionModule.AutonomyStatus.Telemetry.speed', index=0, + number=1, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='fuel', full_name='MissionModule.AutonomyStatus.Telemetry.fuel', index=1, + number=2, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='position', full_name='MissionModule.AutonomyStatus.Telemetry.position', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=216, + serialized_end=299, +) + +_AUTONOMYSTATUS = _descriptor.Descriptor( + name='AutonomyStatus', + full_name='MissionModule.AutonomyStatus', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='telemetry', full_name='MissionModule.AutonomyStatus.telemetry', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='state', full_name='MissionModule.AutonomyStatus.state', index=1, + number=2, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='nextStop', full_name='MissionModule.AutonomyStatus.nextStop', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[_AUTONOMYSTATUS_TELEMETRY, ], + enum_types=[ + _AUTONOMYSTATUS_STATE, + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + _descriptor.OneofDescriptor( + name='_nextStop', full_name='MissionModule.AutonomyStatus._nextStop', + index=0, containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[]), + ], + serialized_start=39, + serialized_end=380, +) + + +_AUTONOMYCOMMAND = _descriptor.Descriptor( + name='AutonomyCommand', + full_name='MissionModule.AutonomyCommand', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='stops', full_name='MissionModule.AutonomyCommand.stops', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='route', full_name='MissionModule.AutonomyCommand.route', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='action', full_name='MissionModule.AutonomyCommand.action', index=2, + number=3, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + _AUTONOMYCOMMAND_ACTION, + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=383, + serialized_end=555, +) + + +_AUTONOMYERROR = _descriptor.Descriptor( + name='AutonomyError', + full_name='MissionModule.AutonomyError', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='finishedStops', full_name='MissionModule.AutonomyError.finishedStops', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=557, + serialized_end=619, +) + + +_STATION = _descriptor.Descriptor( + name='Station', + full_name='MissionModule.Station', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='MissionModule.Station.name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=b"".decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='position', full_name='MissionModule.Station.position', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=621, + serialized_end=687, +) + + +_POSITION = _descriptor.Descriptor( + name='Position', + full_name='MissionModule.Position', + filename=None, + file=DESCRIPTOR, + containing_type=None, + create_key=_descriptor._internal_create_key, + fields=[ + _descriptor.FieldDescriptor( + name='latitude', full_name='MissionModule.Position.latitude', index=0, + number=1, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='longitude', full_name='MissionModule.Position.longitude', index=1, + number=2, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + _descriptor.FieldDescriptor( + name='altitude', full_name='MissionModule.Position.altitude', index=2, + number=3, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR, create_key=_descriptor._internal_create_key), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=689, + serialized_end=754, +) + +_AUTONOMYSTATUS_TELEMETRY.fields_by_name['position'].message_type = _POSITION +_AUTONOMYSTATUS_TELEMETRY.containing_type = _AUTONOMYSTATUS +_AUTONOMYSTATUS.fields_by_name['telemetry'].message_type = _AUTONOMYSTATUS_TELEMETRY +_AUTONOMYSTATUS.fields_by_name['state'].enum_type = _AUTONOMYSTATUS_STATE +_AUTONOMYSTATUS.fields_by_name['nextStop'].message_type = _STATION +_AUTONOMYSTATUS_STATE.containing_type = _AUTONOMYSTATUS +_AUTONOMYSTATUS.oneofs_by_name['_nextStop'].fields.append( + _AUTONOMYSTATUS.fields_by_name['nextStop']) +_AUTONOMYSTATUS.fields_by_name['nextStop'].containing_oneof = _AUTONOMYSTATUS.oneofs_by_name['_nextStop'] +_AUTONOMYCOMMAND.fields_by_name['stops'].message_type = _STATION +_AUTONOMYCOMMAND.fields_by_name['action'].enum_type = _AUTONOMYCOMMAND_ACTION +_AUTONOMYCOMMAND_ACTION.containing_type = _AUTONOMYCOMMAND +_AUTONOMYERROR.fields_by_name['finishedStops'].message_type = _STATION +_STATION.fields_by_name['position'].message_type = _POSITION +DESCRIPTOR.message_types_by_name['AutonomyStatus'] = _AUTONOMYSTATUS +DESCRIPTOR.message_types_by_name['AutonomyCommand'] = _AUTONOMYCOMMAND +DESCRIPTOR.message_types_by_name['AutonomyError'] = _AUTONOMYERROR +DESCRIPTOR.message_types_by_name['Station'] = _STATION +DESCRIPTOR.message_types_by_name['Position'] = _POSITION +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +AutonomyStatus = _reflection.GeneratedProtocolMessageType('AutonomyStatus', (_message.Message,), { + + 'Telemetry' : _reflection.GeneratedProtocolMessageType('Telemetry', (_message.Message,), { + 'DESCRIPTOR' : _AUTONOMYSTATUS_TELEMETRY, + '__module__' : 'MissionModule_pb2' + # @@protoc_insertion_point(class_scope:MissionModule.AutonomyStatus.Telemetry) + }) + , + 'DESCRIPTOR' : _AUTONOMYSTATUS, + '__module__' : 'MissionModule_pb2' + # @@protoc_insertion_point(class_scope:MissionModule.AutonomyStatus) + }) +_sym_db.RegisterMessage(AutonomyStatus) +_sym_db.RegisterMessage(AutonomyStatus.Telemetry) + +AutonomyCommand = _reflection.GeneratedProtocolMessageType('AutonomyCommand', (_message.Message,), { + 'DESCRIPTOR' : _AUTONOMYCOMMAND, + '__module__' : 'MissionModule_pb2' + # @@protoc_insertion_point(class_scope:MissionModule.AutonomyCommand) + }) +_sym_db.RegisterMessage(AutonomyCommand) + +AutonomyError = _reflection.GeneratedProtocolMessageType('AutonomyError', (_message.Message,), { + 'DESCRIPTOR' : _AUTONOMYERROR, + '__module__' : 'MissionModule_pb2' + # @@protoc_insertion_point(class_scope:MissionModule.AutonomyError) + }) +_sym_db.RegisterMessage(AutonomyError) + +Station = _reflection.GeneratedProtocolMessageType('Station', (_message.Message,), { + 'DESCRIPTOR' : _STATION, + '__module__' : 'MissionModule_pb2' + # @@protoc_insertion_point(class_scope:MissionModule.Station) + }) +_sym_db.RegisterMessage(Station) + +Position = _reflection.GeneratedProtocolMessageType('Position', (_message.Message,), { + 'DESCRIPTOR' : _POSITION, + '__module__' : 'MissionModule_pb2' + # @@protoc_insertion_point(class_scope:MissionModule.Position) + }) +_sym_db.RegisterMessage(Position) + + +DESCRIPTOR._options = None +# @@protoc_insertion_point(module_scope) diff --git a/lib/protobuf-mission-module/pyproject.toml b/lib/protobuf-mission-module/pyproject.toml new file mode 100644 index 0000000..665b384 --- /dev/null +++ b/lib/protobuf-mission-module/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "mission_module_protobuf_files" +version = "1.2.13" + + +[tool.setuptools.packages.find] +include = ["mission_module_protobuf_files"] \ No newline at end of file diff --git a/source/bringauto/modules/mission_module/devices/AutonomyDevice.cpp b/source/bringauto/modules/mission_module/devices/AutonomyDevice.cpp index 56b8859..8d4645c 100644 --- a/source/bringauto/modules/mission_module/devices/AutonomyDevice.cpp +++ b/source/bringauto/modules/mission_module/devices/AutonomyDevice.cpp @@ -38,13 +38,14 @@ int AutonomyDevice::generate_command(struct buffer *generated_command, const str auto newAutonomyStatus = protobuf::ProtobufHelper::parseAutonomyStatus(new_status); auto currentAutonomyCommand = protobuf::ProtobufHelper::parseAutonomyCommand(current_command); - if (currentAutonomyStatus.state() == MissionModule::AutonomyStatus_State_DRIVE && - newAutonomyStatus.state() == MissionModule::AutonomyStatus_State_IN_STOP) { - auto* stations = currentAutonomyCommand.mutable_stops(); - if (stations->size() > 0) { - stations->erase(stations->begin()); - } + auto* stations = currentAutonomyCommand.mutable_stops(); + if (stations->size() > 0 && + newAutonomyStatus.state() == MissionModule::AutonomyStatus_State_IN_STOP && + (currentAutonomyStatus.state() == MissionModule::AutonomyStatus_State_DRIVE || + newAutonomyStatus.nextstop().name() == stations->begin()->name())) { + stations->erase(stations->begin()); } + return protobuf::ProtobufHelper::serializeProtobufMessageToBuffer(generated_command, currentAutonomyCommand); } diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..f0a322e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,37 @@ +# Mission Module Tests + +So far only manual tests are implemented. + +## Manual tests + +### Setup + +Set up an [etna](https://github.com/bringauto/etna) repository, tests were created for etna tag v2.5.1. + +Steps to use local mission module changes in etna: +- Set up a [Module Gateway](https://github.com/bringauto/module-gateway) repository, tests were created for Module Gateway tag v1.3.5. +- Push the local mission module changes to a new branch +- Change the mission module version in the module gateway Dockerfile to the new branch name +- Run the create_docker_compose_for_testing.py python script in etna to use a local build of module gateway + +```bash +cd tests +python3 -m venv .venv +source .venv/bin/activate +pip3 install -r requirements.txt +``` + +### MQTT disconnect on stop arrival + +```bash +python3 ./test_disconnect_on_stop_arrival.py --yaml +``` + +This test case runs the default etna scenario and disconnects the mqtt broker as soon as the car reaches the first stop. After roughly 2m 30s the car should reach the next stop automatically without mqtt connection. The test then reconnects the broker and checks for a status error message, which should contain the next stop in its finished stops list. If it doesn't exist, the test fails. + +Path to the etna docker-compose file needs to be provided as an argument. + +Command line arguments: +- `--yaml ` : path to etna docker-compose file. +- `--logging` : enables logging of communication of mission module +- `--verbose` : enables logging of all mqtt communication diff --git a/tests/libs/sniff_mqtt.py b/tests/libs/sniff_mqtt.py new file mode 100644 index 0000000..fee354e --- /dev/null +++ b/tests/libs/sniff_mqtt.py @@ -0,0 +1,219 @@ +import time, threading + +import fleet_protocol_protobuf_files.ExternalProtocol_pb2 as ExternalProtocol_pb2 +import fleet_protocol_protobuf_files.InternalProtocol_pb2 as InternalProtocol_pb2 +import mission_module_protobuf_files.MissionModule_pb2 as MissionModule_pb2 + +from paho.mqtt.client import ( + Client, MQTTMessage, MQTTv311 +) +from paho.mqtt.enums import CallbackAPIVersion + + + +MISSION_MODULE_NUMBER = 1 +MAX_MESSAGES = 100 + + +def mission_module_device() -> InternalProtocol_pb2.Device: + device = InternalProtocol_pb2.Device() + device.module = MISSION_MODULE_NUMBER + device.deviceType = 1 + device.deviceRole = 'autonomy' + device.deviceName = 'Autonomy' + return device + + +class MqttMonitoring: + def __init__(self, verbose: bool = False, store_statuses: bool = False, store_commands: bool = False, logging: bool = False): + self._create_client() + self._verbose = verbose + self._logging = logging + self._store_statuses = store_statuses + self._store_commands = store_commands + self._statuses = [] + self._commands = [] + self._status_lock = threading.Lock() + self._status_condition = threading.Condition(self._status_lock) + self._command_lock = threading.Lock() + self._command_condition = threading.Condition(self._command_lock) + self._mission_module_command_counter = 0 + self._mission_module_session_id = '' + + + def _create_client(self): + self._client = Client( + client_id='Monitoring Test', + protocol=MQTTv311, + transport='tcp', + callback_api_version=CallbackAPIVersion.VERSION2, + reconnect_on_failure=True, + ) + self._client.on_connect = self._on_connect + self._client.on_message = self._on_message + + + def connect(self, host: str = '127.0.0.1', port: int = 1883, keepalive: int = 5) -> None: + """Connects to the MQTT broker with retry logic.""" + connected = False + while not connected: + try: + self._create_client() + self._client.connect(host=host, port=port, keepalive=keepalive) + connected = True + except ConnectionRefusedError: + time.sleep(2) + + + def start(self) -> None: + """Starts the MQTT client loop to listen for messages.""" + self._client.loop_forever(timeout=60) + + + def stop(self) -> None: + """Stops the MQTT client loop and disconnects from the broker.""" + self._client.loop_stop() + self._client.disconnect() + + + def _on_connect(self, client, userdata, flags, rc, properties) -> None: + self._client.subscribe("#", qos=1) + + + def _on_message(self, client, userdata, message: MQTTMessage) -> None: + if self._verbose: self._print(f'topic: {message.topic}') + message_type = message.topic.split('/')[-1] + if message_type == 'module_gateway': + self._print('\nMessage from module gateway:') + self._decode_module_gateway_message(message.payload) + elif message_type == 'external_server': + self._print('\nMessage from external server:') + self._decode_external_server_message(message.payload) + else: + self._print(f'\nUnknown message type: {message_type}') + + + def _decode_module_gateway_message(self, payload: bytes): + message = ExternalProtocol_pb2.ExternalClient() + message.ParseFromString(payload) + if message.HasField('status'): + self._handle_status(getattr(message, 'status').deviceStatus) + elif message.HasField('connect'): + self._print('\tConnect message sent') + elif message.HasField('commandResponse'): + self._print('\tCommand response sent') + else: + self._print('\tMessage with unhandled type') + if self._verbose: self._print(message) + + + def _decode_external_server_message(self, payload: bytes): + message = ExternalProtocol_pb2.ExternalServer() + message.ParseFromString(payload) + if message.HasField('command'): + self._handle_command(getattr(message, 'command')) + elif message.HasField('statusResponse'): + self._print('\tStatus response sent') + elif message.HasField('connectResponse'): + self._print('\tConnect response sent') + else: + self._print('\tMessage with unhandled type') + if self._verbose: self._print(message) + + + def _handle_status(self, status): + if status.device.module == MISSION_MODULE_NUMBER: + self._handle_mission_module_status(status.statusData) + else: + self._print(f'\tStatus from unsupported module: {status.device.module}') + + + def _handle_mission_module_status(self, status): + mission_status = MissionModule_pb2.AutonomyStatus() + mission_status.ParseFromString(status) + if self._store_statuses: + with self._status_condition: + self._statuses.append(mission_status) + if len(self._statuses) > MAX_MESSAGES: + self._statuses.pop(0) + self._status_condition.notify_all() + self._print(f'\tMission Module Status:\n--------------------\n{mission_status}\n--------------------') + + + def get_mission_module_statuses(self): + """Returns a copy of the stored mission module statuses and clears the storage.""" + with self._status_condition: + while not self._statuses: + self._status_condition.wait() + ret = self._statuses.copy() + self._statuses.clear() + return ret + + + def _handle_command(self, command): + if command.deviceCommand.device.module == MISSION_MODULE_NUMBER: + self._mission_module_command_counter = command.messageCounter + self._mission_module_session_id = command.sessionId + self._handle_mission_module_command(command.deviceCommand.commandData) + else: + self._print(f'\tCommand for unsupported module: {command.deviceCommand.device.module}') + + + def _handle_mission_module_command(self, command): + mission_command = MissionModule_pb2.AutonomyCommand() + mission_command.ParseFromString(command) + if self._store_commands: + with self._command_condition: + self._commands.append(mission_command) + if len(self._commands) > MAX_MESSAGES: + self._commands.pop(0) + self._command_condition.notify_all() + self._print(f'\tMission Module Command:\n--------------------\n{mission_command}\n--------------------') + + + def get_mission_module_commands(self): + """Returns a copy of the stored mission module commands and clears the storage.""" + with self._command_condition: + while not self._commands: + self._command_condition.wait() + ret = self._commands.copy() + self._commands.clear() + return ret + + + def _print(self, message: str): + """Prints a message if logging is enabled.""" + if self._logging: + print(message) + + + def send_mission_module_command(self, payload: MissionModule_pb2.AutonomyCommand) -> None: + """Sends a mission module command to the MQTT broker.""" + if not isinstance(payload, MissionModule_pb2.AutonomyCommand): + raise TypeError('Command must be of type AutonomyCommand') + + command = ExternalProtocol_pb2.Command() + command.sessionId = self._mission_module_session_id + command.messageCounter = self._mission_module_command_counter + 1 + + device_command = InternalProtocol_pb2.DeviceCommand() + device_command.device.CopyFrom(mission_module_device()) + device_command.commandData = payload.SerializeToString() + command.deviceCommand.CopyFrom(device_command) + + msg = ExternalProtocol_pb2.ExternalServer() + msg.command.CopyFrom(command) + + self._client.publish( + topic='bringauto/virtual_vehicle/external_server', + payload=msg.SerializeToString(), + qos=1 + ) + + + +if __name__ == '__main__': + print('Waiting for MQTT broker connection...') + mqtt_monitoring = MqttMonitoring(logging=True) + mqtt_monitoring.connect() + mqtt_monitoring.start() diff --git a/tests/libs/testing_utilities.py b/tests/libs/testing_utilities.py new file mode 100644 index 0000000..fb14e46 --- /dev/null +++ b/tests/libs/testing_utilities.py @@ -0,0 +1,71 @@ +import subprocess +import fleet_http_client_python # type: ignore + + + +PROFILE_ALL = 'all' +PROFILE_MQTT = 'mqtt' + +STATE_IDLE = 0 +STATE_DRIVE = 1 +STATE_IN_STOP = 2 + + +def get_baseline_docker_command(yaml_file: str) -> list[str]: + return ['docker', 'compose', '--file', yaml_file ] + + +def run_docker_compose_all(yaml_file: str): + subprocess.run(get_baseline_docker_command(yaml_file) + ['--profile', PROFILE_ALL, 'up', '-d'], check=True) + + +def stop_docker_compose_all(yaml_file: str): + subprocess.run(get_baseline_docker_command(yaml_file) + ['--profile', PROFILE_ALL, 'down'], check=True) + + +def run_docker_compose_profiles(profiles: list[str], yaml_file: str): + command = get_baseline_docker_command(yaml_file) + for profile in profiles: + command += ['--profile', profile] + command += ['up', '-d'] + subprocess.run(command, check=True) + + +def stop_docker_compose_profiles(profiles: list[str], yaml_file: str): + command = get_baseline_docker_command(yaml_file) + for profile in profiles: + command += ['--profile', profile] + command += ['down'] + subprocess.run(command, check=True) + + +def stop_docker_component(name: str): + subprocess.run(['docker', 'stop', name], check=True) + + +def check_car_state(statuses: list, state: int) -> bool: + """Check if any of the statuses has the given state.""" + for status in statuses: + if status.state == state: + return True + return False + + +def check_car_state_and_next_stop(statuses: list, state: int, next_stop: str) -> bool: + """Check if any of the statuses has the given state and next stop.""" + for status in statuses: + if status.state == state and status.nextStop.name == next_stop: + return True + return False + + +def get_status_errors() -> list[fleet_http_client_python.Message]: + """Get all status errors from the fleet HTTP API.""" + configuration = fleet_http_client_python.Configuration(host="http://localhost:8080/v2/protocol", + api_key={"AdminAuth": "ProtocolStaticAccessKey"}) + api_client = fleet_http_client_python.ApiClient(configuration) + device_api = fleet_http_client_python.DeviceApi(api_client) + statuses = device_api.list_statuses(company_name='bringauto', + car_name='virtual_vehicle', + since=0) + return [status for status in statuses if status.payload.message_type == 'STATUS_ERROR' and status.device_id.module_id == 1] diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100755 index 0000000..68d3fc3 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,9 @@ +paho-mqtt == 2.1.0 +protobuf == 3.20 +requests == 2.26.0 +pydantic == 2.7.1 +python-dateutil == 2.9.0.post0 +fleet_management_http_client_python @ git+https://github.com/bringauto/fleet-management-http-client-python.git@v3.1.5 +fleet_http_client_python @ git+https://github.com/bringauto/fleet-http-client-python.git@v2.8.5 +fleet_protocol_protobuf_files @ git+https://github.com/bringauto/fleet-protocol.git@main#subdirectory=protobuf/compiled/python +file:../lib/protobuf-mission-module \ No newline at end of file diff --git a/tests/test_disconnect_on_stop_arrival.py b/tests/test_disconnect_on_stop_arrival.py new file mode 100644 index 0000000..1e5d335 --- /dev/null +++ b/tests/test_disconnect_on_stop_arrival.py @@ -0,0 +1,108 @@ +import argparse +import threading +import time + +import mission_module_protobuf_files.MissionModule_pb2 as MissionModule_pb2 + +import libs.sniff_mqtt +import libs.testing_utilities as _utils + + + +def create_command() -> MissionModule_pb2.AutonomyCommand: + """Creates a command that is expected in the default etna scenario since it reaches the first stop.""" + command = MissionModule_pb2.AutonomyCommand() + command.action = MissionModule_pb2.AutonomyCommand.Action.START + command.route = 'Moravské náměstí 2' + + stops: list[MissionModule_pb2.Station] = [] + stops.append(MissionModule_pb2.Station(name='Svatopluka Čecha A', + position=MissionModule_pb2.Position(latitude=49.221645, + longitude=16.59081))) + stops.append(MissionModule_pb2.Station(name='Těšínská', + position=MissionModule_pb2.Position(latitude=49.22316, + longitude=16.58995))) + command.stops.extend(stops) + return command + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Test disconnecting from MQTT broker on stop arrival.') + parser.add_argument('--yaml', type=str, help='Path to the YAML file for Docker Compose.') + parser.add_argument('--logging', action='store_true', help='Enable mqtt sniffer logging for the test run.') + parser.add_argument('--verbose', action='store_true', help='Enable verbose output for mqtt sniffer logs.') + args = parser.parse_args() + + if not args.yaml: + print('No YAML file provided. Please use --yaml to specify the path to the Docker Compose YAML file.') + exit(1) + + test_passed = False + + # Construct the vernemq docker component name based on the YAML file path. + mqtt_component_name = str(args.yaml).split('/')[-2] + '-vernemq-1' + + print('Starting test: Disconnect on stop arrival') + _utils.stop_docker_compose_all(args.yaml) + time.sleep(5) # Wait for the services to stop completely + _utils.run_docker_compose_all(args.yaml) + print('Waiting for MQTT broker to start...') + time.sleep(5) + mqtt_monitoring = libs.sniff_mqtt.MqttMonitoring(store_statuses=True, + store_commands=True, + logging=args.logging, + verbose=args.verbose) + mqtt_monitoring.connect() + sniff_thread = threading.Thread(target=mqtt_monitoring.start, daemon=True) + sniff_thread.start() + + try: + print('Waiting for car to arrive in stop...') + while True: + statuses = mqtt_monitoring.get_mission_module_statuses() + #print(statuses) + # Wait until the car reaches the Svatopluka Čecha A stop. + if _utils.check_car_state_and_next_stop(statuses, _utils.STATE_IN_STOP, 'Svatopluka Čecha A'): + print('Arrived in stop') + # Send a command that would normally cause the car to be stuck in the stop. Disconnect the MQTT broker at the same time. + mqtt_monitoring.send_mission_module_command(payload=create_command()) + _utils.stop_docker_component(mqtt_component_name) + + print('Waiting for 2m 45s for car to arrive at the next stop...') + time.sleep(165) + _utils.run_docker_compose_profiles([_utils.PROFILE_MQTT], args.yaml) + + print('Waiting for 30 seconds to ensure the car has time to process the reconnection...') + time.sleep(30) + break + + # Check if upon reconnection the car already arrived at the Těšínská stop. + statuses = _utils.get_status_errors() + #print(statuses) + if len(statuses) != 1: + print('Received unexpected number of status errors:', len(statuses)) + else: + finished_stops = statuses[0].payload.data.to_dict()['finishedStops'] + if len(finished_stops) != 1: + print('Unexpected number of finished stops:', len(finished_stops)) + elif finished_stops[0]['name'] != 'Těšínská': + print('Unexpected finished stop name:', finished_stops[0]['name']) + else: + print('Car arrived at the correct stop after disconnecting from the MQTT broker.') + test_passed = True + + + except KeyboardInterrupt: + print('KeyboardInterrupt received, stopping...') + except Exception as e: + print(f'Error: {e}') + + mqtt_monitoring.stop() + sniff_thread.join() + _utils.stop_docker_compose_all(args.yaml) + + if test_passed: + print('\n\033[92mTest passed.\033[0m') + else: + print('\n\033[31mTest failed.\033[0m') + exit(1)