auto-trainer-api 0.9.6__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.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: auto-trainer-api
3
+ Version: 0.9.6
4
+ Summary: API for interfacing with the core acquisition process via platform and language agnostic message queues.
5
+ License: AGPL-3.0-only
6
+ Classifier: Operating System :: OS Independent
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.8
9
+ Requires-Dist: pyhumps==3.8.0
10
+ Requires-Dist: pyzmq==26.4
11
+ Provides-Extra: telemetry
12
+ Requires-Dist: opentelemetry-api; extra == 'telemetry'
13
+ Requires-Dist: opentelemetry-sdk; extra == 'telemetry'
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest==8.2.0; extra == 'test'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Autotrainer API: Python Integration
19
+
20
+ The python `auto-trainer-api` module is intended to provide an efficient means to emit information that
21
+ is needed for local or remote management of applications running locally on the device and to receive commands from
22
+ those sources.
23
+
24
+ The exposed API is intended to be agnostic to the underlying transport layer. The current implementation uses
25
+ ZeroMQ. The reasons for this decision include:
26
+ * Relatively low overhead for the acquisition application
27
+ * Does not require either side to manage connections and know when the other side is available or changes availability
28
+ * Does not require an additional process or service to maintain a persistent message queue (e.g., RabbitMQ)
29
+ * Persistent data is managed elsewhere
30
+
31
+ ## Client Integration
32
+ There are two points of integration available for clients to support the remote interface. The first allows publishing
33
+ "events" for state and property changes that occur in the client. The second is a "command" interface for the client
34
+ to receive command requests from remote sources.
35
+
36
+ Both interfaces are provided through an instance of the `RpcService` protocol. An instance can be obtained via
37
+ `create_api_service(...)` which constructs the specific concrete implementation. After creation, the service must
38
+ be explicitly started (`start(...)`) and can be stopped (`stop(...)`). Once stopped, an instance can not be
39
+ restarted. If a connection should be reestablished after stopping an instance, a new instance should be created and
40
+ started.
41
+
42
+ ### Events
43
+ Events are supported through the `send_dict(topic: ApiTopic, message: dict)` method on the `RpcService` instance.
44
+
45
+ The first argument is the appropriate `ApiTopic` value for the event. The most common for clients to support are
46
+ * `ApiTopic.EVENT` - the topic for most events representing general activity in the about or high-level state changes
47
+ * `ApiTopic.EMERGENCY` - a dedicated topic for emergency-related events such as emergency-stop/resume, alarm changes, etc. [1]
48
+ * `ApiTopic.PROPERTY_CHANGE` - lower-level property changes on specific objects of interest
49
+ * `ApiTopic.COMMAND_RESULT` - command responses for non-immediate commands (see Commands section)
50
+
51
+ The second argument is a dictionary whose contents depend on the topic. All elements must serializable to JSON.
52
+
53
+ #### ApiTopic.EVENT
54
+ The dictionary contains the following entries:
55
+ * `kind` - an `ApiEventKind` value
56
+ * `when` - a wall-clock value of time
57
+ * `index` - monotonically increasing timestamp w/units if nanoseconds
58
+ * `context` - an object whose contents depend on the `ApiEventKind`; may be None for some event kinds
59
+
60
+ #### ApiTopic.EMERGENCY
61
+ Still under development.
62
+
63
+ #### ApiTopic.PROPERTY_CHANGE
64
+ Still under development.
65
+
66
+ #### ApiTopic.COMMAND_RESULT
67
+ An instance of `ApiCommandRequestResponse`.
68
+
69
+ [1] Currently these are supported as special cases of `ApiTopic.EVENT`. At some point these should be transitioned to
70
+ the dedicated topic.
71
+
72
+ ### Commands
73
+ Commands from external sources are supported by registering a `CommandRequestDelegate` with the `RpcService` instance.
74
+ The delegate receives an instance of `ApiCommandRequest` and must return an instance of `ApiCommandRequestResponse`.
75
+
76
+ The primary property of the `ApiCommandRequest` is `command` which is an `ApiCommand` value. Depending on the command,
77
+ there may also be a dictionary in the `data` property with arguments or other information relevant to the command.
78
+ The `nonce` property can be ignored if the command is handled synchronously. For commands that send an
79
+ `ApiTopic.COMMAND_RESULT` event after completion (see below), the nonce must be stored to associate with that event
80
+ (along with the `command` value).
81
+
82
+ The returned `ApiCommandRequestResponse` object contains one require field
83
+ * `result` - a value of `ApiCommandReqeustResult`
84
+
85
+ There are also three optional fields
86
+ * `data` - an optional dictionary with results from the command beyond success/failure (often will be None)
87
+ * `error_code` an integer error code value if the command is not successful or can't be initiated
88
+ * `error_message` an integer error code value if the command is not successful or can't be initiated
89
+
90
+ The expected contents of the `data` property are defined by the command, but is typically `None`.
91
+
92
+ The `error_code` property should be a non-zero value if there is an error code to report.
93
+
94
+ There are two fields on the ApiCommandRequestResponse object that are ignored as part of the returned object from
95
+ the command delegate: `command` and `nonce`. See _Asynchronous Commands_ for when these fields are required.
96
+
97
+
98
+ #### Asynchronous Commands
99
+ The command delegate is expected to return "immediately" (low millisecond type of time frame). If the command is not
100
+ deterministically fast, it is expected to immediately return an `ApiCommandRequestResponse` with a `result` value of
101
+ `ApiCommandReqeustResult.PENDING_WITH_NOTIFICATION`.
102
+
103
+ Once the action associated with the command is complete, the client should send an Event (previous section), with a
104
+ topic of `ApiTopic.COMMAND_RESULT`. The `message` argument of the `send_dict` method should be another instance of
105
+ `ApiCommandRequestResponse`. The `command` and `nonce` properties of the response object should be set to the values
106
+ received in the `ApiCommandRequest` (the client is responsible for storing these values until needed). Note that
107
+ those two properties are ignored for synchronous command handling, but required for asynchronous responses.
108
+
109
+
110
+ ## Publishing
111
+ `python -m build`
112
+
113
+ `python -m twine upload dist/*` (requires PyPi API token)
114
+
115
+ ## Installation
116
+ The package is published to the PyPi package index and can be installed with standard pip commands.
117
+
118
+ `pip install auto-trainer-api`
@@ -0,0 +1,12 @@
1
+ autotrainer/api/__init__.py,sha256=8GUKQ8L-gGgU8XlMztL88zwcjalregEVh__Mo6GGRg4,748
2
+ autotrainer/api/api_event_kind.py,sha256=cKwMDhlTUGY6SM_A1r2vWfHIDPRHYHtSDkWdTt9oDbs,3159
3
+ autotrainer/api/api_options.py,sha256=LJrWqVe6JhYKw9UDL3uJcX4HTkwLW0STaNCGpPWRkoc,761
4
+ autotrainer/api/rpc_service.py,sha256=1r7RFrSgsjfZSMunq1UgA8AEP6oLXF48waM8TC-OMI4,18628
5
+ autotrainer/api/util.py,sha256=RfN6xMgCUahOz6RWMvSUkuw3mz0JEB2cpYoZBVdDY5M,1244
6
+ autotrainer/api/telemtry/__init__.py,sha256=gd013s0gvD2Wc-hj2tGkvQQGNR3u0f8ZGYxbYvITWfo,57
7
+ autotrainer/api/telemtry/open_telemetry_service.py,sha256=V-bDe_UX1I4iPtBgKV4R4tAAuGbsE3kyxGtr5ZD2mJc,2018
8
+ autotrainer/api/zeromq/__init__.py,sha256=L0txGZRsARnVMfuKpEg9E22WkiuoU1FRmzqMAQCqtf4,50
9
+ autotrainer/api/zeromq/zeromq_api_service.py,sha256=WiG7B7rFJNESdDZhzxkmvHY03ei5T9PXeNgRFhJUTjg,4981
10
+ auto_trainer_api-0.9.6.dist-info/METADATA,sha256=3P0jS8siCnK0uIw9MyIjvPfVSq7sPnZV3DozRmPgs8g,6332
11
+ auto_trainer_api-0.9.6.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ auto_trainer_api-0.9.6.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,25 @@
1
+ """
2
+ API Service functionality for Autotrainer.
3
+ """
4
+
5
+ from typing import Optional
6
+
7
+ from .api_options import ApiOptions, create_default_api_options
8
+ from .rpc_service import ApiTopic, ApiCommandRequest, ApiCommandRequestResponse, ApiCommandReqeustResult, RpcService
9
+
10
+ from .util import patch_uuid_encoder
11
+
12
+
13
+ def create_api_service(options: ApiOptions) -> Optional[RpcService]:
14
+ from .zeromq import ZeroMQApiService
15
+
16
+ # Several autotrainer messages may contain a UUID, which is not handled by the default JSON encoder.
17
+ # patch_uuid_encoder()
18
+
19
+ # TODO Enable when ready
20
+ # configure_telemetry(options.telemetry)
21
+
22
+ if options.rpc.enable:
23
+ return ZeroMQApiService(options.rpc)
24
+ else:
25
+ return None
@@ -0,0 +1,120 @@
1
+ from enum import IntEnum
2
+
3
+
4
+ # This is currently a duplicate of what is in the auto-trainer application repository. At some point this will be the
5
+ # source of truth.
6
+ class ApiEventKind(IntEnum):
7
+ """
8
+ 0000-0999 Core/System
9
+ 1000-1999 Behavior
10
+ 2000-2999 Device
11
+ 3000-3999 Inference
12
+ 4000-4999 Analysis
13
+ 5000-5999 Training
14
+ 9000-9999 Application
15
+ """
16
+
17
+ # Core/System
18
+ emergencyStop = 101
19
+ emergencyResume = 102
20
+
21
+ applicationLaunched = 201
22
+ applicationTerminating = 202
23
+
24
+ propertyChanged = 501
25
+
26
+ # Behavior
27
+ algorithmPause = 1001
28
+ algorithmResume = 1002
29
+
30
+ tunnelEnter = 1101
31
+ tunnelExit = 1102
32
+
33
+ pelletLoadCan = 1201
34
+ pelletLoadBegin = 1202
35
+ pelletLoadEnd = 1203
36
+ pelletSendCan = 1204
37
+ pelletSendBegin = 1205
38
+ pelletSendEnd = 1206
39
+ pelletCoverCan = 1207
40
+ pelletCoverBegin = 1208
41
+ pelletCoverEnd = 1209
42
+ pelletReleaseCan = 1210
43
+ pelletReleaseBegin = 1211
44
+ pelletReleaseEnd = 1212
45
+ pelletHomeCan = 1213
46
+ pelletHomeBegin = 1214
47
+ pelletHomeEnd = 1215
48
+ pelletPrereleaseCan = 1216
49
+ pelletPrereleaseBegin = 1217
50
+ pelletPrereleaseEnd = 1218
51
+ pelletAcknowledgeToken = 1298
52
+ pelletExternalToken = 1299
53
+
54
+ sessionStarting = 1301
55
+ sessionStarted = 1302
56
+ sessionEnding = 1303
57
+ sessionEnded = 1304
58
+ sessionPelletIncrease = 1311
59
+ sessionPelletDecrease = 1312
60
+ sessionMouseSeen = 1321
61
+
62
+ dayStarted = 1401
63
+ dayIncreasePellet = 1411
64
+ dayDecreasePellet = 1412
65
+
66
+ pelletSeen = 1501
67
+ pelletPresented = 1502
68
+ pelletSuccessfulReach = 1503
69
+
70
+ triangleSeen = 1550
71
+
72
+ headfixBaselineChanged = 1601
73
+ headfixLoadCellChanged = 1602
74
+ headfixLoadCellChangedInIntersession = 1603
75
+ headfixLoadCellChangedWrongState = 1604
76
+ headfixAutoTare = 1611
77
+
78
+ autoClampIntensityChanged = 1621
79
+ autoClampReleaseToneFreqChanged = 1622
80
+ autoClampReleaseDelayChanged = 1623
81
+
82
+ intersessionSegmentationCan = 1701
83
+ intersessionSegmentationBegin = 1702
84
+ intersessionSegmentationEnd = 1703
85
+ intersessionSegmentationNonceMismatch = 1704
86
+ intersessionSegmentationError = 1705
87
+ intersessionSegmentationSave = 1706
88
+ intersessionSegmentationSaveError = 1707
89
+ intersessionSegmentationInputError = 1708
90
+ intersessionDetectionCan = 1711
91
+ intersessionDetectionBegin = 1712
92
+ intersessionDetectionEnd = 1713
93
+ intersessionDetectionNonceMismatch = 1714
94
+ intersessionDetectionError = 1715
95
+ intersessionDetectionSave = 1716
96
+ intersessionDetectionSaveError = 1717
97
+ intersessionShiftX = 1731
98
+ intersessionShiftY = 1732
99
+ intersessionShiftZ = 1733
100
+
101
+ headFixationForceDetectorChanged = 1801
102
+ headFixationEnabled = 1802
103
+
104
+ # Device
105
+ deviceCommandSend = 2001
106
+ deviceCommandAcknowledge = 2002
107
+
108
+ # Analysis
109
+ loadCellEngagedChanged = 4001
110
+ headbarPressureEngagedChanged = 4011
111
+
112
+ # Training
113
+ trainingModeChanged = 5001
114
+
115
+ trainingPlanLoad = 5101
116
+
117
+ trainingPhaseEnter = 5201
118
+ trainingPhaseExit = 5202
119
+
120
+ trainingProgressUpdate = 5501
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class TelemetryOptions:
7
+ enable: bool = False
8
+ endpoint: Optional[str] = None
9
+ api_key: str = ""
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RpcOptions:
14
+ enable: bool = True
15
+ identifier: str = "autotrainer-device"
16
+ heartbeat_interval: int = 5
17
+ subscriber_port: int = 5556
18
+ command_port: int = 5557
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ApiOptions:
23
+ rpc: Optional[RpcOptions] = None
24
+ telemetry: Optional[TelemetryOptions] = None
25
+
26
+
27
+ def create_default_api_options() -> ApiOptions:
28
+ """
29
+ Create default API options for the Autotrainer API service.
30
+ """
31
+
32
+ return ApiOptions(rpc=RpcOptions(), telemetry=TelemetryOptions())
@@ -0,0 +1,473 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from dataclasses import dataclass, asdict
5
+ from enum import IntEnum
6
+ from queue import Queue, Empty
7
+ from threading import Timer, Thread
8
+ from typing import Optional, Protocol, Any
9
+ from typing_extensions import Self
10
+
11
+ import humps
12
+
13
+ from .api_event_kind import ApiEventKind
14
+ from .api_options import RpcOptions
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class ApiTopic(IntEnum):
20
+ """
21
+ A topic is required for all published messages. This allows subscribers to filter messages through the message
22
+ queue functionality rather than seeing all messages and filtering themselves.
23
+
24
+ Any 4-byte integer value is valid.
25
+ """
26
+ ANY = 0
27
+ HEARTBEAT = 1001
28
+ """System heartbeat message indicating service availability."""
29
+ EMERGENCY = 2001
30
+ """Emergency only messages."""
31
+ EVENT = 4001
32
+ """
33
+ Data generated from the published under the 'Event' umbrella, which is typically major system/application events.
34
+ """
35
+ PROPERTY_CHANGE = 5001
36
+ """
37
+ Data generated from the published under the 'Event' umbrella, which is typically major system/application events.
38
+ """
39
+ COMMAND_RESULT = 6001
40
+ """ Responses to asynchronous command handling. """
41
+
42
+
43
+ class ApiCommand(IntEnum):
44
+ """
45
+ A command value is required for all command requests.
46
+ """
47
+ NONE = 0
48
+ START_ACQUISITION = 100
49
+ STOP_ACQUISITION = 110
50
+ EMERGENCY_STOP = 130
51
+ EMERGENCY_RESUME = 135
52
+ """No command. Can be used to verify connection or that the receiver is configured to receive commands."""
53
+ USER_DEFINED = 99999
54
+ """A custom command defined between a particular commander and receiver. Additional information can be
55
+ defined in the `data` field."""
56
+
57
+ @classmethod
58
+ def is_member(cls, value):
59
+ return value in cls._value2member_map_
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ApiCommandRequest:
64
+ """
65
+ A command request contains the command and any associated data.
66
+ """
67
+ command: ApiCommand
68
+ custom_command: int = -1
69
+ nonce: int = -1
70
+ data: Optional[dict] = None
71
+
72
+ @classmethod
73
+ def parse_bytes(cls, message: bytes) -> Optional[Self]:
74
+ obj = json.loads(humps.decamelize(message.decode("utf8")))
75
+
76
+ return ApiCommandRequest.parse_object(obj)
77
+
78
+ @classmethod
79
+ def parse_object(cls, obj: Any) -> Optional[Self]:
80
+ if "command" in obj:
81
+ command = obj["command"]
82
+ if ApiCommand.is_member(command):
83
+ command = ApiCommand(command)
84
+ else:
85
+ command = ApiCommand.USER_DEFINED
86
+ if "custom_command" in obj:
87
+ custom_command = obj["custom_command"]
88
+ else:
89
+ custom_command = -1
90
+ if "data" in obj:
91
+ data = obj["data"]
92
+ else:
93
+ data = None
94
+ if "nonce" in obj:
95
+ nonce = obj["nonce"]
96
+ else:
97
+ nonce = 0
98
+
99
+ return ApiCommandRequest(command=command, custom_command=custom_command, nonce=nonce, data=data)
100
+
101
+ return None
102
+
103
+
104
+ class ApiCommandReqeustResult(IntEnum):
105
+ """
106
+ Result of a command request.
107
+ """
108
+ UNRECOGNIZED = 0
109
+ """The client does not support this operation."""
110
+ SUCCESS = 100
111
+ """the command executed successfully."""
112
+ PENDING = 200
113
+ """The command was initiated but may not be complete."""
114
+ PENDING_WITH_NOTIFICATION = 201
115
+ """Command was initiated. A specific event will post when the command completes."""
116
+ FAILED = 400
117
+ """The command was attempted, but could not be completed."""
118
+ EXCEPTION = 500
119
+ """The command was attempted and generated an exception."""
120
+ UNAVAILABLE = 9999
121
+ """The command is recognized, but not supported at this time."""
122
+
123
+
124
+ class ApiCommandRequestErrorKind(IntEnum):
125
+ NONE = 0
126
+ SYSTEM_ERROR = 0x01
127
+ COMMAND_ERROR = 0x02
128
+
129
+
130
+ ApiCommandRequestSystemErrorSerialization: int = 0
131
+
132
+ NotificationEventKinds = [ApiEventKind.emergencyStop, ApiEventKind.emergencyResume]
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class ApiCommandRequestResponse:
137
+ result: ApiCommandReqeustResult
138
+
139
+ data: Optional[dict] = None
140
+
141
+ error_code: int = 0
142
+ error_message: Optional[str] = None
143
+
144
+ command: ApiCommand = ApiCommand.NONE
145
+ """For synchronous command handling, this does not need to be set."""
146
+ nonce: int = -1
147
+ """For synchronous command handling, this does not need to be set."""
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class ApiCommandRequestServiceResponse:
152
+ """
153
+ A command response contains the command and any associated data.
154
+
155
+ If the command request will have a result asynchronously in the future, one pattern would be to return some form
156
+ of context in the data field that the client can use as a reference in a future published message.
157
+ """
158
+ nonce: int
159
+ """
160
+ This will be set to 0 if a command request could not be deserialized. Caller should set the nonce to any value > 0
161
+ if they want to be able to identify commands that were not simple unrecognized, but unparseable.
162
+ """
163
+ command: ApiCommand
164
+ result: ApiCommandReqeustResult = ApiCommandReqeustResult.UNRECOGNIZED
165
+ data: Optional[dict] = None
166
+ error_kind: ApiCommandRequestErrorKind = ApiCommandRequestErrorKind.NONE
167
+ error_code: int = 0
168
+ error_message: Optional[str] = None
169
+
170
+ def as_bytes(self, allow_fallback: bool = True) -> bytes:
171
+ """
172
+ This is guaranteed to return a valid response, even if modified due to any errors in serialization. It must
173
+ not throw.
174
+
175
+ :param allow_fallback: true to allow serialization without the 'data' element if serialization initially fails.
176
+ :return: serialized message as bytes
177
+ """
178
+ try:
179
+ return humps.camelize(json.dumps(self.__dict__)).encode("utf8")
180
+ except Exception as ex:
181
+ logger.error(ex)
182
+
183
+ if allow_fallback:
184
+ # Assume it is an issue w/the contents of the user-definable dictionary contents
185
+ contents = self.__dict__
186
+ contents["data"] = None
187
+ # If the next attempt works, this will have been the situation.
188
+ contents["error_kind"] = ApiCommandRequestErrorKind.SYSTEM_ERROR
189
+ contents["error_code"] = ApiCommandRequestSystemErrorSerialization
190
+ contents["error_message"] = "An error occurred serializing the 'data' element of the response."
191
+ try:
192
+ return humps.camelize(json.dumps(contents)).encode("utf8")
193
+ except Exception as ex:
194
+ logger.error(ex)
195
+
196
+ serialization_error = {"nonce": self.nonce, "command": self.command, "result": self.result,
197
+ "error_kind": ApiCommandRequestErrorKind.SYSTEM_ERROR,
198
+ "error_code": ApiCommandRequestSystemErrorSerialization,
199
+ "error_message": "An error occurred serializing the response."}
200
+
201
+ return humps.camelize(json.dumps(serialization_error)).encode("utf8")
202
+
203
+ @staticmethod
204
+ def for_exception(command: ApiCommand, nonce: int, ex: Exception):
205
+ return ApiCommandRequestServiceResponse(command=command, nonce=nonce, result=ApiCommandReqeustResult.EXCEPTION,
206
+ error_message=str(ex))
207
+
208
+
209
+ class CommandRequestDelegate(Protocol):
210
+ """
211
+ This callback is expected to be fast. It is intended to initiate a command, not necessarily complete it. Any
212
+ non-trivial action is expected to accept the command request and return, perform the action on a non-calling thread
213
+ or process, and use the message publishing API to report changes, results, etc.
214
+ """
215
+
216
+ def __call__(self, request: ApiCommandRequest) -> ApiCommandRequestResponse: ...
217
+
218
+
219
+ class ApiMessageQueueService(Protocol):
220
+ """
221
+ Minimum requirements to fulfill the API service message queue interface. Implementation details are left to the
222
+ implementation.
223
+
224
+ Implementations are required to be able to publish messages to one or more subscribers.
225
+ """
226
+
227
+ def send(self, topic: ApiTopic, data: bytes) -> bool: ...
228
+
229
+ def send_string(self, topic: ApiTopic, message: str) -> bool: ...
230
+
231
+ def send_dict(self, topic: ApiTopic, message: dict) -> bool: ...
232
+
233
+
234
+ class ApiCommandRequestService(Protocol):
235
+ """
236
+ Minimum requirements to fulfill the API service command provider interface. Implementation details are left to the
237
+ implementation.
238
+
239
+ Implementations are required to be able to receive command requests from one or more clients, deliver those requests
240
+ to a registered handler, and provide an immediate response to the requester. The response is a response to the
241
+ _command request_ not necessarily the response to the command itself. See CommandCallback for additional details.
242
+ """
243
+
244
+ @property
245
+ def command_request_delegate(self) -> Optional[CommandRequestDelegate]: ...
246
+
247
+ @command_request_delegate.setter
248
+ def command_request_delegate(self, value: Optional[CommandRequestDelegate]): ...
249
+
250
+
251
+ @dataclass
252
+ class HeartbeatMessage:
253
+ identifier: str
254
+ version: str
255
+ timestamp: float
256
+
257
+
258
+ class RpcService:
259
+ def __init__(self, options: RpcOptions):
260
+ self._subscriber_port = options.subscriber_port
261
+ self._command_port = options.command_port
262
+ self._identifier = options.identifier
263
+ self._heartbeat_interval = options.heartbeat_interval
264
+ self._heartbeat_timer = None
265
+ self._heartbeat: HeartbeatMessage = HeartbeatMessage(
266
+ identifier=options.identifier,
267
+ version="0.9.6",
268
+ timestamp=0.0
269
+ )
270
+
271
+ self._command_callback: Optional[CommandRequestDelegate] = None
272
+
273
+ self._thread = None
274
+
275
+ self._termination_requested = False
276
+
277
+ self._queue = Queue()
278
+
279
+ @property
280
+ def subscriber_port(self) -> int:
281
+ return self._subscriber_port
282
+
283
+ @property
284
+ def command_port(self) -> int:
285
+ return self._command_port
286
+
287
+ @property
288
+ def identifier(self) -> str:
289
+ return self._identifier
290
+
291
+ @property
292
+ def heartbeat_interval(self) -> int:
293
+ return self._heartbeat_interval
294
+
295
+ @property
296
+ def command_request_delegate(self) -> Optional[CommandRequestDelegate]:
297
+ return self._command_callback
298
+
299
+ @command_request_delegate.setter
300
+ def command_request_delegate(self, value: Optional[CommandRequestDelegate]):
301
+ if not self._termination_requested:
302
+ self._command_callback = value
303
+ else:
304
+ logger.warning("The RPC service has been terminated. command_request_delegate not set")
305
+
306
+ def start(self):
307
+ if self._thread is None and not self._termination_requested:
308
+ self._queue = Queue()
309
+ self._thread = Thread(target=self._run, name="RpcServiceThread")
310
+ self._thread.start()
311
+ else:
312
+ raise RuntimeError("The RPC service has already been started.")
313
+
314
+ def stop(self):
315
+ if self._thread is not None and not self._termination_requested:
316
+ self._termination_requested = True
317
+ self._command_callback = None
318
+ if self._thread.is_alive():
319
+ self._thread.join()
320
+
321
+ def send(self, topic: ApiTopic, data: bytes) -> bool:
322
+ if self._queue is None:
323
+ raise RuntimeError("The RPC service has not been started.")
324
+
325
+ if not self._termination_requested:
326
+ self._queue.put(lambda: self._send(topic, data))
327
+ return True
328
+
329
+ logger.warning("The RPC service has been terminated. Message not sent.")
330
+ return False
331
+
332
+ def send_string(self, topic: ApiTopic, message: str) -> bool:
333
+ if self._queue is None:
334
+ raise RuntimeError("The RPC service has not been started.")
335
+
336
+ if not self._termination_requested:
337
+ self._queue.put(lambda: self._send_string(topic, message))
338
+ return True
339
+
340
+ logger.warning("The RPC service has been terminated. Message not sent.")
341
+ return False
342
+
343
+ def send_dict(self, topic: ApiTopic, message: dict) -> bool:
344
+ if self._queue is None:
345
+ raise RuntimeError("The RPC service has not been started.")
346
+
347
+ if not self._termination_requested:
348
+ if topic == ApiTopic.EVENT and "kind" in message and message["kind"] in NotificationEventKinds:
349
+ # If an emergency event is posted in the normal event channel, ensure it goes out on the emergency
350
+ # topic as well.
351
+ self._queue.put(lambda: self._send_dict(ApiTopic.EMERGENCY, message))
352
+ self._queue.put(lambda: self._send_dict(topic, message))
353
+
354
+ return True
355
+
356
+ logger.warning("The RPC service has been terminated. Message not sent.")
357
+ return False
358
+
359
+ def _run(self):
360
+ try:
361
+ if not self._start():
362
+ logger.error(f"failed to start api service")
363
+ self._update_after_run()
364
+ return
365
+ except Exception:
366
+ logger.exception("exception starting api service")
367
+ self._update_after_run()
368
+ return
369
+
370
+ self._queue_heartbeat()
371
+
372
+ while not self._termination_requested:
373
+ # Required to be non-blocking and exception safe by the rpc-specific implementation.
374
+ request = self._get_next_command_request()
375
+
376
+ if request is not None:
377
+ # Expected to happen fast. Most message queue implementations that provide a request/response-type
378
+ # pattern require that a response be sent before the next request is received. And the associated
379
+ # client/caller implementation requires the response before accepting another request.
380
+ #
381
+ # If this becomes an untenable requirement, a different pattern will be required for command
382
+ # requests (and the underlying implementations update). However, anything that allows interleaving
383
+ # multiple requests from the same client with responses would effectively be the same as this
384
+ # pattern where there is an immediate _request_ response and a delayed _command_ response through
385
+ # the message queue.
386
+ #
387
+ # NOTE: There is no inherent limitation in this pattern with multiple requests from multiple
388
+ # clients. This is related to the handling of each individual client.
389
+
390
+ if self._command_callback is not None:
391
+ try:
392
+ # Can not assume the registered delegate will be well-behaved.
393
+ client_response = self._command_callback(request)
394
+
395
+ error_kind = ApiCommandRequestErrorKind.COMMAND_ERROR if client_response.error_code != 0 else ApiCommandRequestErrorKind.NONE
396
+
397
+ self._send_command_response(
398
+ ApiCommandRequestServiceResponse(request.nonce, request.command, client_response.result,
399
+ client_response.data, error_kind,
400
+ client_response.error_code, client_response.error_message))
401
+ except Exception as e:
402
+ self._send_command_response(
403
+ ApiCommandRequestServiceResponse.for_exception(request.command, request.nonce, e))
404
+ else:
405
+ # Must provide a response, even if no one that registered this service cares (using for the
406
+ # message queue only, etc.).
407
+ self._send_command_response(
408
+ ApiCommandRequestServiceResponse(command=request.command, nonce=request.nonce,
409
+ result=ApiCommandReqeustResult.UNAVAILABLE))
410
+
411
+ try:
412
+ action = self._queue.get(timeout=0.05)
413
+
414
+ # This is performed by the internal rpc implementation. It should be exception safe or the
415
+ # implementation should be corrected.
416
+ action()
417
+
418
+ except Empty:
419
+ # Expected from get_nowait() if there is nothing in the queue.
420
+ pass
421
+
422
+ self._update_after_run()
423
+
424
+ def _update_after_run(self):
425
+ self._cancel_heartbeat()
426
+
427
+ self._stop()
428
+
429
+ def _queue_heartbeat(self):
430
+ self._heartbeat_timer = Timer(self._heartbeat_interval, self._heartbeat_timer_callback)
431
+ self._heartbeat_timer.start()
432
+
433
+ def _cancel_heartbeat(self):
434
+ if self._heartbeat_timer is not None:
435
+ self._heartbeat_timer.cancel()
436
+ self._heartbeat_timer = None
437
+
438
+ def _heartbeat_timer_callback(self):
439
+ # Must happen on the queue processing thread.
440
+ if not self._termination_requested:
441
+ self._heartbeat.timestamp = time.time()
442
+ self._send_dict(ApiTopic.HEARTBEAT, asdict(self._heartbeat))
443
+ self._queue_heartbeat()
444
+
445
+ def _start(self) -> bool:
446
+ raise NotImplementedError("Subclasses must implement _start()")
447
+
448
+ def _stop(self):
449
+ raise NotImplementedError("Subclasses must implement _stop()")
450
+
451
+ def _send(self, topic: ApiTopic, data: bytes) -> bool:
452
+ return False
453
+
454
+ def _send_string(self, topic: ApiTopic, message: str) -> bool:
455
+ return False
456
+
457
+ def _send_dict(self, topic: ApiTopic, message: dict) -> bool:
458
+ return False
459
+
460
+ def _get_next_command_request(self) -> Optional[ApiCommandRequest]:
461
+ """
462
+ If a command request is returned, the subclass/implementation can assume that a responses will be sent
463
+ (via the `_command_response()_` method). If something is received by the implementation that can not be
464
+ returned as a valid command request (malformed, incomplete, etc.), it is the responsibility of the
465
+ implementation to provide a response if that is required by the particular implementation (e.g., request/reply
466
+ requiring a response to every request, etc.).
467
+
468
+ :return: a command request if available, None otherwise
469
+ """
470
+ return None
471
+
472
+ def _send_command_response(self, response: ApiCommandRequestServiceResponse):
473
+ pass
@@ -0,0 +1 @@
1
+ from .open_telemetry_service import configure_telemetry
@@ -0,0 +1,51 @@
1
+ import importlib.util
2
+ import logging
3
+ import os
4
+ from typing import Optional
5
+
6
+ from ..api_options import TelemetryOptions
7
+
8
+ _spec_opentelemetry = importlib.util.find_spec("opentelemetry")
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def configure_telemetry(options: Optional[TelemetryOptions]) -> bool:
14
+ if options is None or not options.enable:
15
+ logger.debug(f"telemetry options {'missing' if options is None else 'disabled'}.")
16
+ return False
17
+
18
+ if _spec_opentelemetry is None:
19
+ logger.warning("telemetry enabled however a required dependency is missing.")
20
+ return False
21
+
22
+ endpoint = options.endpoint if options.endpoint is not None else os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
23
+
24
+ if endpoint is None:
25
+ logger.warning("telemetry enabled however the endpoint is not specified.")
26
+ return False
27
+
28
+ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
29
+
30
+ from opentelemetry import trace
31
+ from opentelemetry.sdk.trace import TracerProvider
32
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
33
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
34
+
35
+ from opentelemetry import metrics
36
+ from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
37
+ from opentelemetry.sdk.metrics import MeterProvider
38
+ from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
39
+
40
+ resource = Resource(attributes={SERVICE_NAME: "auto-trainer"})
41
+
42
+ trace_provider = TracerProvider(resource=resource)
43
+ processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{endpoint}/v1/traces"))
44
+ trace_provider.add_span_processor(processor)
45
+ trace.set_tracer_provider(trace_provider)
46
+
47
+ reader = PeriodicExportingMetricReader(OTLPMetricExporter(endpoint=f"{endpoint}/v1/metrics"))
48
+ meter_provider = MeterProvider(resource=resource, metric_readers=[reader])
49
+ metrics.set_meter_provider(meter_provider)
50
+
51
+ return True
@@ -0,0 +1,40 @@
1
+ import socket
2
+ from datetime import datetime
3
+ from json import JSONEncoder
4
+ from uuid import UUID
5
+
6
+ _IS_JSON_ENCODER_PATCHED = False
7
+
8
+
9
+ def get_ip4_addr_str() -> str:
10
+ # gethostname() and gethostbyname() and associated IP lookup have proven unreliable on deployed devices where the
11
+ # configuration is not perfect. This method assumes access to the internet (Google DNS) which has its own
12
+ # limitations. A more complex implementation to manage all conditions but avoid ending up with 12.0.0.1 when an
13
+ # actual address is available is needed.
14
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
15
+ try:
16
+ s.connect(("8.8.8.8", 80))
17
+ ip = s.getsockname()[0]
18
+ except Exception:
19
+ ip = "127.0.0.1"
20
+ finally:
21
+ s.close()
22
+
23
+ return ip
24
+
25
+
26
+ def patch_uuid_encoder():
27
+ global _IS_JSON_ENCODER_PATCHED
28
+
29
+ if not _IS_JSON_ENCODER_PATCHED:
30
+ JSONEncoder.default = UUIDEncoder.default
31
+ _IS_JSON_ENCODER_PATCHED = True
32
+
33
+
34
+ class UUIDEncoder(JSONEncoder):
35
+ def default(self, obj):
36
+ if isinstance(obj, UUID):
37
+ return str(obj)
38
+ if isinstance(obj, datetime):
39
+ return obj.timestamp()
40
+ return super().default(obj)
@@ -0,0 +1 @@
1
+ from .zeromq_api_service import ZeroMQApiService
@@ -0,0 +1,132 @@
1
+ import json
2
+ import logging
3
+ from typing import Optional
4
+
5
+ import zmq
6
+ import humps
7
+
8
+ from ..rpc_service import RpcService, RpcOptions, ApiTopic, ApiCommandRequestServiceResponse, ApiCommandRequest, ApiCommand
9
+ from ..util import get_ip4_addr_str, UUIDEncoder
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class ZeroMQApiService(RpcService):
15
+ def __init__(self, options: RpcOptions):
16
+ super().__init__(options)
17
+
18
+ ips = [get_ip4_addr_str()]
19
+
20
+ if ips[0] != "127.0.0.1":
21
+ ips.append("127.0.0.1")
22
+
23
+ self._pub_addresses = [f"tcp://{ip}:{self.subscriber_port}" for ip in ips]
24
+ self._pub_socket = None
25
+
26
+ self._cmd_addresses = [f"tcp://{ip}:{self.command_port}" for ip in ips]
27
+ self._cmd_socket = None
28
+
29
+ self._response_pending = False
30
+
31
+ def _start(self) -> bool:
32
+ if self._pub_socket is not None:
33
+ return True
34
+
35
+ try:
36
+ context = zmq.Context()
37
+
38
+ self._pub_socket = context.socket(zmq.PUB)
39
+ for address in self._pub_addresses:
40
+ self._pub_socket.bind(address)
41
+ logger.debug(f"ZMQ PUB socket bound to {address}")
42
+
43
+ context = zmq.Context()
44
+
45
+ self._cmd_socket = context.socket(zmq.REP)
46
+ for address in self._cmd_addresses:
47
+ self._cmd_socket.bind(address)
48
+ logger.debug(f"ZMQ REP socket bound to {address}")
49
+ except zmq.error.ZMQError:
50
+ self._pub_socket = None
51
+ self._cmd_socket = None
52
+ logger.info(f"ZMQ not started. Address may already be in use.")
53
+ return False
54
+
55
+ return True
56
+
57
+ def _stop(self):
58
+ if self._pub_socket is not None:
59
+ for address in self._pub_addresses:
60
+ self._pub_socket.disconnect(address)
61
+ self._pub_socket = None
62
+ if self._cmd_socket is not None:
63
+ for address in self._cmd_addresses:
64
+ self._cmd_socket.disconnect(address)
65
+ self._cmd_socket = None
66
+
67
+ def _send(self, topic: ApiTopic, data: bytes):
68
+ if self._pub_socket is not None:
69
+ self._pub_socket.send(topic.to_bytes(4, "little"), flags=zmq.SNDMORE)
70
+ self._pub_socket.send(data)
71
+
72
+ def _send_string(self, topic: ApiTopic, message: str):
73
+ if self._pub_socket is not None:
74
+ self._pub_socket.send(topic.to_bytes(4, "little"), flags=zmq.SNDMORE)
75
+ self._pub_socket.send(message.encode("utf8"))
76
+
77
+ def _send_dict(self, topic: ApiTopic, message: dict):
78
+ if self._pub_socket is not None:
79
+ try:
80
+ # Convert the dictionary to a JSON string and then encode it to bytes.
81
+ json_data = humps.camelize(json.dumps(message, cls=UUIDEncoder))
82
+ self._pub_socket.send(topic.to_bytes(4, "little"), flags=zmq.SNDMORE)
83
+ self._pub_socket.send_json(json_data)
84
+ except Exception as ex:
85
+ logger.error(ex)
86
+
87
+ def _get_next_command_request(self) -> Optional[ApiCommandRequest]:
88
+ if self._cmd_socket is not None and not self._response_pending:
89
+ try:
90
+ message = self._cmd_socket.recv(flags=zmq.NOBLOCK)
91
+
92
+ self._response_pending = True
93
+
94
+ request = ZeroMQApiService._parse_command_request(message)
95
+
96
+ logger.info(f"Received command request: {request.command}")
97
+
98
+ if request is None:
99
+ # If a request was received, but could not be parsed, the requester is still expecting a response.
100
+ # Otherwise, the ZeroMQ socket on both ends will be in a lingering state. Send it ourselves since
101
+ # returning None will not generate a response by the caller.
102
+ self._send_command_response(ApiCommandRequestServiceResponse(command=ApiCommand.NONE, nonce=0))
103
+
104
+ # A response will be sent by the caller if a request is returned.
105
+ return request
106
+
107
+ except zmq.Again:
108
+ # No messages available from recv().
109
+ pass
110
+
111
+ return None
112
+
113
+ def _send_command_response(self, response: ApiCommandRequestServiceResponse):
114
+ if self._cmd_socket is not None:
115
+ # This is guaranteed to return a valid response, even if modified due to any errors in serialization.
116
+ data = response.as_bytes(True)
117
+
118
+ self._cmd_socket.send(data)
119
+
120
+ self._response_pending = False
121
+
122
+ @staticmethod
123
+ def _parse_command_request(message: bytes) -> Optional[ApiCommandRequest]:
124
+ try:
125
+ return ApiCommandRequest.parse_bytes(message)
126
+ except json.decoder.JSONDecodeError as ex:
127
+ # Might do something different here.
128
+ logger.error(ex)
129
+ except Exception as ex:
130
+ logger.error(ex)
131
+
132
+ return None