isar 1.32.3__py3-none-any.whl → 1.33.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of isar might be problematic. Click here for more details.

Files changed (35) hide show
  1. isar/apis/models/models.py +6 -0
  2. isar/apis/schedule/scheduling_controller.py +21 -52
  3. isar/config/open_telemetry.py +52 -12
  4. isar/config/settings.py +17 -3
  5. isar/eventhandlers/eventhandler.py +22 -0
  6. isar/models/events.py +56 -27
  7. isar/robot/robot_status.py +3 -0
  8. isar/services/utilities/scheduling_utilities.py +52 -21
  9. isar/state_machine/state_machine.py +39 -1
  10. isar/state_machine/states/await_next_mission.py +3 -1
  11. isar/state_machine/states/home.py +3 -1
  12. isar/state_machine/states/monitor.py +22 -0
  13. isar/state_machine/states/recharging.py +44 -0
  14. isar/state_machine/states/returning_home.py +15 -1
  15. isar/state_machine/states/robot_standing_still.py +3 -1
  16. isar/state_machine/states/stopping.py +33 -0
  17. isar/state_machine/states_enum.py +1 -0
  18. isar/state_machine/transitions/functions/pause.py +1 -1
  19. isar/state_machine/transitions/functions/resume.py +1 -1
  20. isar/state_machine/transitions/functions/start_mission.py +11 -3
  21. isar/state_machine/transitions/functions/stop.py +3 -30
  22. isar/state_machine/transitions/mission.py +0 -2
  23. isar/state_machine/transitions/return_home.py +11 -1
  24. isar/state_machine/transitions/robot_status.py +10 -0
  25. isar/state_machine/utils/common_event_handlers.py +16 -4
  26. {isar-1.32.3.dist-info → isar-1.33.1.dist-info}/METADATA +1 -1
  27. {isar-1.32.3.dist-info → isar-1.33.1.dist-info}/RECORD +35 -34
  28. robot_interface/models/inspection/inspection.py +6 -15
  29. robot_interface/models/mission/status.py +1 -0
  30. robot_interface/robot_interface.py +27 -0
  31. robot_interface/telemetry/payloads.py +10 -0
  32. {isar-1.32.3.dist-info → isar-1.33.1.dist-info}/WHEEL +0 -0
  33. {isar-1.32.3.dist-info → isar-1.33.1.dist-info}/entry_points.txt +0 -0
  34. {isar-1.32.3.dist-info → isar-1.33.1.dist-info}/licenses/LICENSE +0 -0
  35. {isar-1.32.3.dist-info → isar-1.33.1.dist-info}/top_level.txt +0 -0
@@ -26,6 +26,12 @@ class ControlMissionResponse(BaseModel):
26
26
  task_status: Optional[str]
27
27
 
28
28
 
29
+ class MissionStartResponse(BaseModel):
30
+ mission_id: Optional[str] = None
31
+ mission_started: bool
32
+ mission_not_started_reason: Optional[str] = None
33
+
34
+
29
35
  class RobotInfoResponse(BaseModel):
30
36
  robot_package: str
31
37
  isar_id: str
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  from http import HTTPStatus
3
- from threading import Lock
4
3
 
5
4
  from fastapi import Body, HTTPException, Path
6
5
 
@@ -29,7 +28,6 @@ class SchedulingController:
29
28
  ):
30
29
  self.scheduling_utilities: SchedulingUtilities = scheduling_utilities
31
30
  self.logger = logging.getLogger("api")
32
- self.start_mission_lock: Lock = Lock()
33
31
 
34
32
  def start_mission_by_id(
35
33
  self,
@@ -77,67 +75,38 @@ class SchedulingController:
77
75
  detail=error_message_no_mission_definition,
78
76
  )
79
77
 
80
- if not self.start_mission_lock.acquire(blocking=False):
81
- error_message_another_mission_starting: str = (
82
- "Conflict - Another mission is currently being started"
83
- )
84
- self.logger.warning(error_message_another_mission_starting)
85
- raise HTTPException(
86
- status_code=HTTPStatus.CONFLICT,
87
- detail=error_message_another_mission_starting,
88
- )
78
+ state: States = self.scheduling_utilities.get_state()
79
+ self.scheduling_utilities.verify_state_machine_ready_to_receive_mission(state)
89
80
 
90
81
  try:
91
- state: States = self.scheduling_utilities.get_state()
92
- self.scheduling_utilities.verify_state_machine_ready_to_receive_mission(
93
- state
82
+ mission: Mission = to_isar_mission(
83
+ start_mission_definition=mission_definition
94
84
  )
95
-
96
- try:
97
- mission: Mission = to_isar_mission(
98
- start_mission_definition=mission_definition
99
- )
100
- except MissionPlannerError as e:
101
- error_message = f"Bad Request - Cannot create ISAR mission: {e}"
102
- self.logger.warning(error_message)
103
- raise HTTPException(
104
- status_code=HTTPStatus.BAD_REQUEST,
105
- detail=error_message,
106
- )
107
-
108
- self.scheduling_utilities.verify_robot_capable_of_mission(
109
- mission=mission, robot_capabilities=robot_settings.CAPABILITIES
85
+ except MissionPlannerError as e:
86
+ error_message = f"Bad Request - Cannot create ISAR mission: {e}"
87
+ self.logger.warning(error_message)
88
+ raise HTTPException(
89
+ status_code=HTTPStatus.BAD_REQUEST,
90
+ detail=error_message,
110
91
  )
111
92
 
112
- self.logger.info("Starting mission: %s", mission.id)
113
- self.scheduling_utilities.start_mission(mission=mission)
114
- return self._api_response(mission)
93
+ self.scheduling_utilities.verify_robot_capable_of_mission(
94
+ mission=mission, robot_capabilities=robot_settings.CAPABILITIES
95
+ )
115
96
 
116
- finally:
117
- self.start_mission_lock.release()
97
+ self.logger.info("Starting mission: %s", mission.id)
98
+ self.scheduling_utilities.start_mission(mission=mission)
99
+ return self._api_response(mission)
118
100
 
119
101
  def return_home(self) -> None:
120
102
  self.logger.info("Received request to return home")
121
103
 
122
- if not self.start_mission_lock.acquire(blocking=False):
123
- error_message_another_mission_starting: str = (
124
- "Conflict - Another mission is currently being started"
125
- )
126
- self.logger.warning(error_message_another_mission_starting)
127
- raise HTTPException(
128
- status_code=HTTPStatus.CONFLICT,
129
- detail=error_message_another_mission_starting,
130
- )
131
-
132
- try:
133
- state: States = self.scheduling_utilities.get_state()
134
- self.scheduling_utilities.verify_state_machine_ready_to_receive_return_home_mission(
135
- state
136
- )
104
+ state: States = self.scheduling_utilities.get_state()
105
+ self.scheduling_utilities.verify_state_machine_ready_to_receive_return_home_mission(
106
+ state
107
+ )
137
108
 
138
- self.scheduling_utilities.return_home()
139
- finally:
140
- self.start_mission_lock.release()
109
+ self.scheduling_utilities.return_home()
141
110
 
142
111
  def pause_mission(self) -> ControlMissionResponse:
143
112
  self.logger.info("Received request to pause current mission")
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ from urllib.parse import urljoin
2
3
 
3
4
  from azure.monitor.opentelemetry.exporter import (
4
5
  AzureMonitorLogExporter,
@@ -7,6 +8,12 @@ from azure.monitor.opentelemetry.exporter import (
7
8
  from fastapi import FastAPI
8
9
  from opentelemetry import trace
9
10
  from opentelemetry._logs import set_logger_provider
11
+ from opentelemetry.exporter.otlp.proto.http._log_exporter import (
12
+ OTLPLogExporter as OTLPHttpLogExporter,
13
+ )
14
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
15
+ OTLPSpanExporter as OTLPHttpSpanExporter,
16
+ )
10
17
  from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
11
18
  from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
12
19
  from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
@@ -17,22 +24,45 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor
17
24
  from isar.config.log import load_log_config
18
25
  from isar.config.settings import settings
19
26
 
27
+ logging.getLogger("opentelemetry.sdk").setLevel(logging.CRITICAL)
28
+
20
29
 
21
30
  def setup_open_telemetry(app: FastAPI) -> None:
22
- if not settings.LOG_HANDLER_APPLICATION_INSIGHTS_ENABLED:
23
- return
24
- trace_exporter, log_exporter = get_azure_monitor_exporters()
25
31
 
26
- service_name = settings.OPEN_TELEMETRY_SERVICE_NAME
32
+ service_name = settings.ROBOT_NAME
27
33
  resource = Resource.create({SERVICE_NAME: service_name})
28
34
 
29
35
  tracer_provider = TracerProvider(resource=resource)
30
- tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter))
31
- trace.set_tracer_provider(tracer_provider)
32
-
33
36
  log_provider = LoggerProvider(resource=resource)
37
+
38
+ if settings.LOG_HANDLER_APPLICATION_INSIGHTS_ENABLED:
39
+ print("[OTEL] Azure Monitor exporters enabled")
40
+ azure_monitor_trace_exporter, azure_monitor_log_exporter = (
41
+ get_azure_monitor_exporters()
42
+ )
43
+
44
+ tracer_provider.add_span_processor(
45
+ BatchSpanProcessor(azure_monitor_trace_exporter)
46
+ )
47
+
48
+ log_provider.add_log_record_processor(
49
+ BatchLogRecordProcessor(azure_monitor_log_exporter)
50
+ )
51
+
52
+ otlp_exporter_endpoint = settings.OPEN_TELEMETRY_OTLP_EXPORTER_ENDPOINT
53
+ if otlp_exporter_endpoint:
54
+ print(f"[OTEL] OTLP exporters enabled, endpoint={otlp_exporter_endpoint}")
55
+ otlp_trace_exporter, otlp_log_exporter = get_otlp_exporters(
56
+ otlp_exporter_endpoint
57
+ )
58
+ tracer_provider.add_span_processor(BatchSpanProcessor(otlp_trace_exporter))
59
+
60
+ log_provider.add_log_record_processor(
61
+ BatchLogRecordProcessor(otlp_log_exporter)
62
+ )
63
+
34
64
  set_logger_provider(log_provider)
35
- log_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
65
+ trace.set_tracer_provider(tracer_provider)
36
66
 
37
67
  handler = LoggingHandler(logger_provider=log_provider)
38
68
  attach_loggers_for_open_telemetry(handler)
@@ -51,12 +81,22 @@ def attach_loggers_for_open_telemetry(handler: LoggingHandler):
51
81
  def get_azure_monitor_exporters() -> (
52
82
  tuple[AzureMonitorTraceExporter, AzureMonitorLogExporter]
53
83
  ):
54
- """
55
- If connection string is defined in environment variables, then use it to create Azure Monitor Exporters.
56
- Else use Azure Managed Identity to create Azure Monitor Exporters.
57
- """
58
84
  connection_string = settings.APPLICATIONINSIGHTS_CONNECTION_STRING
59
85
  trace_exporter = AzureMonitorTraceExporter(connection_string=connection_string)
60
86
  log_exporter = AzureMonitorLogExporter(connection_string=connection_string)
61
87
 
62
88
  return trace_exporter, log_exporter
89
+
90
+
91
+ def get_otlp_exporters(
92
+ endpoint: str,
93
+ ) -> tuple[OTLPHttpSpanExporter, OTLPHttpLogExporter]:
94
+ base = endpoint.rstrip("/") + "/"
95
+ trace_ep = urljoin(base, "v1/traces")
96
+ log_ep = urljoin(base, "v1/logs")
97
+
98
+ print("[OTEL] Using HTTP/Protobuf protocol for OpenTelemetry export")
99
+ print(f"[OTEL] traces → {trace_ep}")
100
+ print(f"[OTEL] logs → {log_ep}")
101
+
102
+ return OTLPHttpSpanExporter(endpoint=trace_ep), OTLPHttpLogExporter(endpoint=log_ep)
isar/config/settings.py CHANGED
@@ -12,8 +12,9 @@ from robot_interface.telemetry.payloads import DocumentInfo
12
12
 
13
13
 
14
14
  class Settings(BaseSettings):
15
- # Name of the OpenTelemetry service
16
- OPEN_TELEMETRY_SERVICE_NAME: str = Field(default="isar")
15
+ # Endpoint open telemetry will export telemetry in the otlp protocol to
16
+ OPEN_TELEMETRY_OTLP_EXPORTER_ENDPOINT: str = Field(default="http://localhost:4318")
17
+
17
18
  # Connection string for Azure Application Insights
18
19
  # This is optional and it will use managed identity if not set
19
20
  APPLICATIONINSIGHTS_CONNECTION_STRING: str = Field(default="")
@@ -29,7 +30,7 @@ class Settings(BaseSettings):
29
30
  REQUEST_TIMEOUT: int = Field(default=30)
30
31
 
31
32
  # Timeout in seconds for checking whether there is a message on a queue
32
- QUEUE_TIMEOUT: int = Field(default=10)
33
+ QUEUE_TIMEOUT: int = Field(default=3)
33
34
 
34
35
  # Sleep time for while loops in the finite state machine in seconds
35
36
  # The sleep is used to throttle the system on every iteration in the loop
@@ -80,6 +81,15 @@ class Settings(BaseSettings):
80
81
  ROBOT_API_STATUS_POLL_INTERVAL: float = Field(default=5)
81
82
  THREAD_CHECK_INTERVAL: float = Field(default=0.01)
82
83
 
84
+ # Determines the minimum battery level the robot must have to start a mission
85
+ # If it drops below this level it will recharge to the value set by
86
+ # ROBOT_BATTERY_RECHARGE_THRESHOLD before starting new missions
87
+ ROBOT_MISSION_BATTERY_START_THRESHOLD: float = Field(default=25.0)
88
+
89
+ # Determines the minimum battery threshold to consider the robot recharged
90
+ # and ready for more missions, after having run low on charge
91
+ ROBOT_BATTERY_RECHARGE_THRESHOLD: float = Field(default=80.0)
92
+
83
93
  # FastAPI host
84
94
  API_HOST_VIEWED_EXTERNALLY: str = Field(default="0.0.0.0")
85
95
 
@@ -215,6 +225,9 @@ class Settings(BaseSettings):
215
225
  # List of MQTT Topics
216
226
  TOPIC_ISAR_STATUS: str = Field(default="status", validate_default=True)
217
227
  TOPIC_ISAR_MISSION: str = Field(default="mission", validate_default=True)
228
+ TOPIC_ISAR_MISSION_ABORTED: str = Field(
229
+ default="aborted_mission", validate_default=True
230
+ )
218
231
  TOPIC_ISAR_TASK: str = Field(default="task", validate_default=True)
219
232
  TOPIC_ISAR_INSPECTION_RESULT: str = Field(
220
233
  default="inspection_result", validate_default=True
@@ -282,6 +295,7 @@ class Settings(BaseSettings):
282
295
  "TOPIC_ISAR_INSPECTION_VALUE",
283
296
  "TOPIC_ISAR_STARTUP",
284
297
  "TOPIC_ISAR_INTERVENTION_NEEDED",
298
+ "TOPIC_ISAR_MISSION_ABORTED",
285
299
  )
286
300
  @classmethod
287
301
  def prefix_isar_topics(cls, v: Any, info: ValidationInfo):
@@ -58,6 +58,28 @@ class EventHandlerBase(State):
58
58
  def stop(self) -> None:
59
59
  return
60
60
 
61
+ def get_event_handler_by_name(
62
+ self, event_handler_name: str
63
+ ) -> Optional[EventHandlerMapping]:
64
+ filtered_handlers = list(
65
+ filter(
66
+ lambda mapping: mapping.name == event_handler_name,
67
+ self.event_handler_mappings,
68
+ )
69
+ )
70
+ return filtered_handlers[0] if len(filtered_handlers) > 0 else None
71
+
72
+ def get_event_timer_by_name(
73
+ self, event_timer_name: str
74
+ ) -> Optional[TimeoutHandlerMapping]:
75
+ filtered_timers = list(
76
+ filter(
77
+ lambda mapping: mapping.name == event_timer_name,
78
+ self.timers,
79
+ )
80
+ )
81
+ return filtered_timers[0] if len(filtered_timers) > 0 else None
82
+
61
83
  def _run(self) -> None:
62
84
  should_exit_state: bool = False
63
85
  timers = deepcopy(self.timers)
isar/models/events.py CHANGED
@@ -4,7 +4,7 @@ from typing import Generic, Optional, TypeVar
4
4
 
5
5
  from transitions import State
6
6
 
7
- from isar.apis.models.models import ControlMissionResponse
7
+ from isar.apis.models.models import ControlMissionResponse, MissionStartResponse
8
8
  from isar.config.settings import settings
9
9
  from robot_interface.models.exceptions.robot_exceptions import ErrorMessage
10
10
  from robot_interface.models.mission.mission import Mission
@@ -17,11 +17,19 @@ T2 = TypeVar("T2")
17
17
 
18
18
 
19
19
  class Event(Queue[T]):
20
- def __init__(self) -> None:
20
+ def __init__(self, name: str) -> None:
21
21
  super().__init__(maxsize=1)
22
+ self.name = name
22
23
 
23
- def trigger_event(self, data: T) -> None:
24
- self.put(data)
24
+ def trigger_event(self, data: T, timeout: int = None) -> None:
25
+ try:
26
+ # We always want a timeout when blocking for results, so that
27
+ # the thread will never get stuck waiting for a result
28
+ self.put(data, block=timeout is not None, timeout=timeout)
29
+ except Exception:
30
+ if timeout is not None:
31
+ raise EventTimeoutError
32
+ return None
25
33
 
26
34
  def consume_event(self, timeout: int = None) -> Optional[T]:
27
35
  try:
@@ -74,46 +82,67 @@ class APIEvent(Generic[T1, T2]):
74
82
  api to state machine while the response is from state machine to api.
75
83
  """
76
84
 
77
- def __init__(self):
78
- self.request: Event[T1] = Event()
79
- self.response: Event[T2] = Event()
85
+ def __init__(self, name: str):
86
+ self.request: Event[T1] = Event("api-" + name + "-request")
87
+ self.response: Event[T2] = Event("api-" + name + "-request")
80
88
 
81
89
 
82
90
  class APIRequests:
83
91
  def __init__(self) -> None:
84
- self.start_mission: APIEvent[Mission, bool] = APIEvent()
85
- self.stop_mission: APIEvent[str, ControlMissionResponse] = APIEvent()
86
- self.pause_mission: APIEvent[bool, ControlMissionResponse] = APIEvent()
87
- self.resume_mission: APIEvent[bool, ControlMissionResponse] = APIEvent()
88
- self.return_home: APIEvent[bool, bool] = APIEvent()
89
- self.release_intervention_needed: APIEvent[bool, bool] = APIEvent()
92
+ self.start_mission: APIEvent[Mission, MissionStartResponse] = APIEvent(
93
+ "start_mission"
94
+ )
95
+ self.stop_mission: APIEvent[str, ControlMissionResponse] = APIEvent(
96
+ "stop_mission"
97
+ )
98
+ self.pause_mission: APIEvent[bool, ControlMissionResponse] = APIEvent(
99
+ "pause_mission"
100
+ )
101
+ self.resume_mission: APIEvent[bool, ControlMissionResponse] = APIEvent(
102
+ "resume_mission"
103
+ )
104
+ self.return_home: APIEvent[bool, bool] = APIEvent("return_home")
105
+ self.release_intervention_needed: APIEvent[bool, bool] = APIEvent(
106
+ "release_intervention_needed"
107
+ )
90
108
 
91
109
 
92
110
  class StateMachineEvents:
93
111
  def __init__(self) -> None:
94
- self.start_mission: Event[Mission] = Event()
95
- self.stop_mission: Event[bool] = Event()
96
- self.pause_mission: Event[bool] = Event()
97
- self.task_status_request: Event[str] = Event()
112
+ self.start_mission: Event[Mission] = Event("start_mission")
113
+ self.stop_mission: Event[bool] = Event("stop_mission")
114
+ self.pause_mission: Event[bool] = Event("pause_mission")
115
+ self.task_status_request: Event[str] = Event("task_status_request")
98
116
 
99
117
 
100
118
  class RobotServiceEvents:
101
119
  def __init__(self) -> None:
102
- self.task_status_updated: Event[TaskStatus] = Event()
103
- self.task_status_failed: Event[ErrorMessage] = Event()
104
- self.mission_started: Event[bool] = Event()
105
- self.mission_failed: Event[ErrorMessage] = Event()
106
- self.robot_status_changed: Event[bool] = Event()
107
- self.mission_failed_to_stop: Event[ErrorMessage] = Event()
108
- self.mission_successfully_stopped: Event[bool] = Event()
120
+ self.task_status_updated: Event[TaskStatus] = Event("task_status_updated")
121
+ self.task_status_failed: Event[ErrorMessage] = Event("task_status_failed")
122
+ self.mission_started: Event[bool] = Event("mission_started")
123
+ self.mission_failed: Event[ErrorMessage] = Event("mission_failed")
124
+ self.robot_status_changed: Event[bool] = Event("robot_status_changed")
125
+ self.mission_failed_to_stop: Event[ErrorMessage] = Event(
126
+ "mission_failed_to_stop"
127
+ )
128
+ self.mission_successfully_stopped: Event[bool] = Event(
129
+ "mission_successfully_stopped"
130
+ )
109
131
 
110
132
 
111
133
  class SharedState:
112
134
  def __init__(self) -> None:
113
- self.state: Event[State] = Event()
114
- self.robot_status: Event[RobotStatus] = Event()
115
- self.state_machine_current_task: Event[TASKS] = Event()
135
+ self.state: Event[State] = Event("state")
136
+ self.robot_status: Event[RobotStatus] = Event("robot_status")
137
+ self.state_machine_current_task: Event[TASKS] = Event(
138
+ "state_machine_current_task"
139
+ )
140
+ self.robot_battery_level: Event[float] = Event("robot_battery_level")
116
141
 
117
142
 
118
143
  class EventTimeoutError(Exception):
119
144
  pass
145
+
146
+
147
+ class EventConflictError(Exception):
148
+ pass
@@ -48,7 +48,10 @@ class RobotStatusThread(Thread):
48
48
  self.last_robot_status_poll_time = time.time()
49
49
 
50
50
  robot_status = self.robot.robot_status()
51
+ robot_battery_level = self.robot.get_battery_level()
52
+
51
53
  self.shared_state.robot_status.update(robot_status)
54
+ self.shared_state.robot_battery_level.update(robot_battery_level)
52
55
  except RobotException as e:
53
56
  self.logger.error(f"Failed to retrieve robot status: {e}")
54
57
  continue
@@ -16,6 +16,7 @@ from isar.mission_planner.mission_planner_interface import (
16
16
  from isar.models.events import (
17
17
  APIEvent,
18
18
  APIRequests,
19
+ EventConflictError,
19
20
  Events,
20
21
  EventTimeoutError,
21
22
  SharedState,
@@ -176,16 +177,28 @@ class SchedulingUtilities:
176
177
  If there is a timeout while communicating with the state machine
177
178
  """
178
179
  try:
179
- self._send_command(
180
+ mission_start_response = self._send_command(
180
181
  deepcopy(mission),
181
182
  self.api_events.start_mission,
182
183
  )
184
+ if not mission_start_response.mission_started:
185
+ self.logger.warning(
186
+ f"Mission failed to start - {mission_start_response.mission_not_started_reason}"
187
+ )
188
+ raise HTTPException(
189
+ status_code=HTTPStatus.CONFLICT,
190
+ detail=mission_start_response.mission_not_started_reason,
191
+ )
192
+ except EventConflictError:
193
+ error_message = "Previous mission request is still being processed"
194
+ self.logger.warning(error_message)
195
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
183
196
  except EventTimeoutError:
184
- error_message = "Internal Server Error - Failed to start mission in ISAR"
185
- self.logger.error(error_message)
186
- raise HTTPException(
187
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=error_message
197
+ error_message = (
198
+ "State machine has entered a state which cannot start a mission"
188
199
  )
200
+ self.logger.warning(error_message)
201
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
189
202
  self.logger.info("OK - Mission started in ISAR")
190
203
 
191
204
  def return_home(
@@ -203,14 +216,14 @@ class SchedulingUtilities:
203
216
  True,
204
217
  self.api_events.return_home,
205
218
  )
219
+ except EventConflictError:
220
+ error_message = "Previous return home request is still being processed"
221
+ self.logger.warning(error_message)
222
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
206
223
  except EventTimeoutError:
207
- error_message = (
208
- "Internal Server Error - Failed to start return home mission in ISAR"
209
- )
210
- self.logger.error(error_message)
211
- raise HTTPException(
212
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=error_message
213
- )
224
+ error_message = "State machine has entered a state which cannot start a return home mission"
225
+ self.logger.warning(error_message)
226
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
214
227
  self.logger.info("OK - Return home mission started in ISAR")
215
228
 
216
229
  def pause_mission(self) -> ControlMissionResponse:
@@ -225,12 +238,16 @@ class SchedulingUtilities:
225
238
  response = self._send_command(True, self.api_events.pause_mission)
226
239
  self.logger.info("OK - Mission successfully paused")
227
240
  return response
241
+ except EventConflictError:
242
+ error_message = "Previous pause mission request is still being processed"
243
+ self.logger.warning(error_message)
244
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
228
245
  except EventTimeoutError:
229
- error_message = "Internal Server Error - Failed to pause mission"
230
- self.logger.error(error_message)
231
- raise HTTPException(
232
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=error_message
246
+ error_message = (
247
+ "State machine has entered a state which cannot pause a mission"
233
248
  )
249
+ self.logger.warning(error_message)
250
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
234
251
 
235
252
  def resume_mission(self) -> ControlMissionResponse:
236
253
  """Resume mission
@@ -244,6 +261,10 @@ class SchedulingUtilities:
244
261
  response = self._send_command(True, self.api_events.resume_mission)
245
262
  self.logger.info("OK - Mission successfully resumed")
246
263
  return response
264
+ except EventConflictError:
265
+ error_message = "Previous resume mission request is still being processed"
266
+ self.logger.warning(error_message)
267
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
247
268
  except EventTimeoutError:
248
269
  error_message = "Internal Server Error - Failed to resume mission"
249
270
  self.logger.error(error_message)
@@ -281,12 +302,16 @@ class SchedulingUtilities:
281
302
  raise HTTPException(
282
303
  status_code=HTTPStatus.CONFLICT, detail=error_message
283
304
  )
305
+ except EventConflictError:
306
+ error_message = "Previous stop mission request is still being processed"
307
+ self.logger.warning(error_message)
308
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
284
309
  except EventTimeoutError:
285
- error_message = "Internal Server Error - Failed to stop mission"
286
- self.logger.error(error_message)
287
- raise HTTPException(
288
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=error_message
310
+ error_message = (
311
+ "State machine has entered a state which cannot stop a mission"
289
312
  )
313
+ self.logger.warning(error_message)
314
+ raise HTTPException(status_code=HTTPStatus.CONFLICT, detail=error_message)
290
315
  self.logger.info("OK - Mission successfully stopped")
291
316
  return stop_mission_response
292
317
 
@@ -311,11 +336,17 @@ class SchedulingUtilities:
311
336
  )
312
337
 
313
338
  def _send_command(self, input: T1, api_event: APIEvent[T1, T2]) -> T2:
314
- api_event.request.trigger_event(input)
339
+ if api_event.request.has_event() or api_event.response.has_event():
340
+ raise EventConflictError("API event has already been sent")
341
+
315
342
  try:
343
+ api_event.request.trigger_event(input, timeout=1)
316
344
  return api_event.response.consume_event(timeout=self.queue_timeout)
317
345
  except EventTimeoutError as e:
318
346
  self.logger.error("Queue timed out")
319
347
  api_event.request.clear_event()
320
348
  self.logger.error("No output received for command to state machine")
321
349
  raise e
350
+ finally:
351
+ api_event.request.clear_event()
352
+ api_event.response.clear_event()
@@ -22,6 +22,7 @@ from isar.state_machine.states.intervention_needed import InterventionNeeded
22
22
  from isar.state_machine.states.monitor import Monitor
23
23
  from isar.state_machine.states.offline import Offline
24
24
  from isar.state_machine.states.paused import Paused
25
+ from isar.state_machine.states.recharging import Recharging
25
26
  from isar.state_machine.states.returning_home import ReturningHome
26
27
  from isar.state_machine.states.robot_standing_still import RobotStandingStill
27
28
  from isar.state_machine.states.stopping import Stopping
@@ -43,6 +44,7 @@ from robot_interface.robot_interface import RobotInterface
43
44
  from robot_interface.telemetry.mqtt_client import MqttClientInterface
44
45
  from robot_interface.telemetry.payloads import (
45
46
  InterventionNeededPayload,
47
+ MissionAbortedPayload,
46
48
  MissionPayload,
47
49
  RobotStatusPayload,
48
50
  TaskPayload,
@@ -108,6 +110,7 @@ class StateMachine(object):
108
110
  # Status states
109
111
  self.offline_state: State = Offline(self)
110
112
  self.blocked_protective_stopping_state: State = BlockedProtectiveStop(self)
113
+ self.recharging_state: State = Recharging(self)
111
114
 
112
115
  # Error and special status states
113
116
  self.unknown_status_state: State = UnknownStatus(self)
@@ -124,6 +127,7 @@ class StateMachine(object):
124
127
  self.blocked_protective_stopping_state,
125
128
  self.unknown_status_state,
126
129
  self.intervention_needed_state,
130
+ self.recharging_state,
127
131
  ]
128
132
 
129
133
  self.machine = Machine(
@@ -189,6 +193,12 @@ class StateMachine(object):
189
193
  self.current_task = None
190
194
  self.send_task_status()
191
195
 
196
+ def battery_level_is_above_mission_start_threshold(self):
197
+ return (
198
+ not self.shared_state.robot_battery_level.check()
199
+ < settings.ROBOT_MISSION_BATTERY_START_THRESHOLD
200
+ )
201
+
192
202
  def update_state(self):
193
203
  """Updates the current state of the state machine."""
194
204
  self.current_state = States(self.state) # type: ignore
@@ -227,6 +237,32 @@ class StateMachine(object):
227
237
  f"Task: {str(task.id)[:8]} was reported as task.status by the robot"
228
238
  )
229
239
 
240
+ def publish_mission_aborted(self, reason: str, can_be_continued: bool) -> None:
241
+ if not self.mqtt_publisher:
242
+ return
243
+
244
+ if self.current_mission is None:
245
+ self.logger.warning(
246
+ "Could not publish mission aborted message. No ongoing mission."
247
+ )
248
+ return
249
+
250
+ payload: MissionAbortedPayload = MissionAbortedPayload(
251
+ isar_id=settings.ISAR_ID,
252
+ robot_name=settings.ROBOT_NAME,
253
+ mission_id=self.current_mission.id,
254
+ reason=reason,
255
+ can_be_continued=can_be_continued,
256
+ timestamp=datetime.now(timezone.utc),
257
+ )
258
+
259
+ self.mqtt_publisher.publish(
260
+ topic=settings.TOPIC_ISAR_MISSION_ABORTED,
261
+ payload=json.dumps(payload, cls=EnhancedJSONEncoder),
262
+ qos=1,
263
+ retain=True,
264
+ )
265
+
230
266
  def publish_mission_status(self) -> None:
231
267
  if not self.mqtt_publisher:
232
268
  return
@@ -339,6 +375,8 @@ class StateMachine(object):
339
375
  return RobotStatus.BlockedProtectiveStop
340
376
  elif self.current_state == States.InterventionNeeded:
341
377
  return RobotStatus.InterventionNeeded
378
+ elif self.current_state == States.Recharging:
379
+ return RobotStatus.Recharging
342
380
  else:
343
381
  return RobotStatus.Busy
344
382
 
@@ -368,7 +406,7 @@ class StateMachine(object):
368
406
  )
369
407
 
370
408
  def _queue_empty_response(self) -> None:
371
- self.events.api_requests.stop_mission.response.put(
409
+ self.events.api_requests.stop_mission.response.trigger_event(
372
410
  ControlMissionResponse(
373
411
  mission_id="None",
374
412
  mission_status="None",
@@ -25,7 +25,9 @@ class AwaitNextMission(EventHandlerBase):
25
25
  EventHandlerMapping(
26
26
  name="start_mission_event",
27
27
  event=events.api_requests.start_mission.request,
28
- handler=lambda event: start_mission_event_handler(state_machine, event),
28
+ handler=lambda event: start_mission_event_handler(
29
+ state_machine, event, events.api_requests.start_mission.response
30
+ ),
29
31
  ),
30
32
  EventHandlerMapping(
31
33
  name="return_home_event",