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.
- auto_trainer_api-0.9.6.dist-info/METADATA +118 -0
- auto_trainer_api-0.9.6.dist-info/RECORD +12 -0
- auto_trainer_api-0.9.6.dist-info/WHEEL +4 -0
- autotrainer/api/__init__.py +25 -0
- autotrainer/api/api_event_kind.py +120 -0
- autotrainer/api/api_options.py +32 -0
- autotrainer/api/rpc_service.py +473 -0
- autotrainer/api/telemtry/__init__.py +1 -0
- autotrainer/api/telemtry/open_telemetry_service.py +51 -0
- autotrainer/api/util.py +40 -0
- autotrainer/api/zeromq/__init__.py +1 -0
- autotrainer/api/zeromq/zeromq_api_service.py +132 -0
|
@@ -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,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
|
autotrainer/api/util.py
ADDED
|
@@ -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
|