motifapi 0.2.0__tar.gz
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.
- motifapi-0.2.0/MANIFEST.in +1 -0
- motifapi-0.2.0/PKG-INFO +19 -0
- motifapi-0.2.0/README.md +610 -0
- motifapi-0.2.0/motifapi/__init__.py +5 -0
- motifapi-0.2.0/motifapi/api.py +328 -0
- motifapi-0.2.0/motifapi/schedule.py +2 -0
- motifapi-0.2.0/motifapi/server.crt +19 -0
- motifapi-0.2.0/motifapi/stream.py +100 -0
- motifapi-0.2.0/motifapi/util.py +16 -0
- motifapi-0.2.0/motifapi.egg-info/PKG-INFO +19 -0
- motifapi-0.2.0/motifapi.egg-info/SOURCES.txt +13 -0
- motifapi-0.2.0/motifapi.egg-info/dependency_links.txt +1 -0
- motifapi-0.2.0/motifapi.egg-info/top_level.txt +1 -0
- motifapi-0.2.0/setup.cfg +4 -0
- motifapi-0.2.0/setup.py +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include motifapi/server.crt
|
motifapi-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: motifapi
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python interface to Motif recording systems
|
|
5
|
+
Author: John Stowers
|
|
6
|
+
Author-email: john@loopbio.com
|
|
7
|
+
License: BSD
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
|
+
Dynamic: author
|
|
12
|
+
Dynamic: author-email
|
|
13
|
+
Dynamic: classifier
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: license
|
|
16
|
+
Dynamic: requires-python
|
|
17
|
+
Dynamic: summary
|
|
18
|
+
|
|
19
|
+
Python interface to Motif recording systems
|
motifapi-0.2.0/README.md
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
API for Motif Recording Systems (Python Wrapper)
|
|
2
|
+
================================================
|
|
3
|
+
|
|
4
|
+
[This library](https://github.com/loopbio/python-motifapi) allows you to control cameras
|
|
5
|
+
and recording on loopbio [motif](http://loopbio.com/recording) recording systems, including
|
|
6
|
+
the ability to control outputs and schedule operations. This allows you to implement
|
|
7
|
+
experimental or operational protocols like;
|
|
8
|
+
|
|
9
|
+
* "start recording every hour for 30 minutes. while recording give this stimulus (switch this output)
|
|
10
|
+
every 5 minutes"
|
|
11
|
+
* "record at 9am and 3pm. at 5pm copy all recorded videos to network storage"
|
|
12
|
+
* "while recording, at minute 1 switch this output on, then every 30 seconds thereafter, switch on and
|
|
13
|
+
off this other output"
|
|
14
|
+
|
|
15
|
+
**Table of Contents**
|
|
16
|
+
|
|
17
|
+
* [Examples](#examples)
|
|
18
|
+
* [Examples (IO and Scheduling)](#scheduling-examples)
|
|
19
|
+
* [API Documentation](#api-documentation)
|
|
20
|
+
* [Experiment Script Messages](#experiment-script-messages)
|
|
21
|
+
* [Scheduling](#scheduling-function)
|
|
22
|
+
* [API Documentation (Scheduling)](#scheduling-api-documentation)
|
|
23
|
+
* [MATLAB](#matlab) and [Other Language](#other-languages) support
|
|
24
|
+
* [Realtime Streaming](#realtime-streaming)
|
|
25
|
+
|
|
26
|
+
## Getting Started
|
|
27
|
+
|
|
28
|
+
* this library works out of the box on Python2 and Python3 and has no dependencies outside
|
|
29
|
+
of the standard library
|
|
30
|
+
* this library wraps the REST+JSON API and handles sending and parsing the responses
|
|
31
|
+
* this library is compatible with all motif versions, although real-time image streaming and
|
|
32
|
+
some IO operations are only supported in motif 5 and above
|
|
33
|
+
* to control a motif recording system you need to know it's `IP_ADDRESS` and an `API_KEY`.
|
|
34
|
+
* the IP address is that which you browse to in order to control the recording software
|
|
35
|
+
using your web browser
|
|
36
|
+
* the API key is specific to the installation of the recording software and secures the
|
|
37
|
+
API from unauthorized access. To find the API key for the recording system you wish to
|
|
38
|
+
control, SSH into the machine (or request your sysadmin to) and execute the following
|
|
39
|
+
command `recnode-apikey`
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
### Examples
|
|
43
|
+
|
|
44
|
+
Set up the connection to the machine
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from motifapi import MotifApi
|
|
48
|
+
|
|
49
|
+
IP_ADDRESS = '10.11.12.23'
|
|
50
|
+
API_KEY = 'abcdef123456abcdef123456abcdef12'
|
|
51
|
+
|
|
52
|
+
api = MotifApi(IP_ADDRESS, API_KEY)
|
|
53
|
+
# check the client is connected
|
|
54
|
+
api.call('version')
|
|
55
|
+
# list the connected cameras
|
|
56
|
+
api.call('cameras')
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
To start recording on all cameras with the predefined compression settings named 'high'
|
|
60
|
+
(see the web UI for the names of your configured compression formats). The recording will run
|
|
61
|
+
for 5 seconds and will have have the following metadata attached.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
api.call('recording/start',
|
|
65
|
+
codec='high',
|
|
66
|
+
duration=5,
|
|
67
|
+
metadata={'foo': 1, 'bar': 'bob'})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
To stop all recordings on all cameras
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
api.call('recording/stop')
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To configure one camera and start recording on it alone
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
api.call('camera/FAKE0/configure', Gain=0))
|
|
80
|
+
api.call('camera/FAKE0/recording/start', codec='low')
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Cameras are identified by their serial number ('FAKE0' in the example above). To find the serial
|
|
84
|
+
numbers of connected cameras do
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
api.call('cameras')
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
To find the status of a camera (is it recording, uploading, etc) do
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
api.call('camera/FAKE0')
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**See `examples/*.py` for further examples of API usage**
|
|
97
|
+
|
|
98
|
+
## API Documentation
|
|
99
|
+
|
|
100
|
+
The complete list of API endpoints/paths, as passed to `call(path, **arguments)`,
|
|
101
|
+
can be found below. Text between `<` and `>` characters should be replaced
|
|
102
|
+
with the appropriate values. Arguments are passed after the path, e.g.
|
|
103
|
+
`api.call('recording/start', duration=5.0)`.
|
|
104
|
+
|
|
105
|
+
* `version`
|
|
106
|
+
* return the current software version
|
|
107
|
+
* `cameras`
|
|
108
|
+
* return a list of connected cameras, their names and serial numbers
|
|
109
|
+
* `camera/<serial>`
|
|
110
|
+
* returns the selected camera status
|
|
111
|
+
* `serial`: the serial number of the camera
|
|
112
|
+
* `camera/<serial>/configure`
|
|
113
|
+
* change camera configuration
|
|
114
|
+
* `serial`: the serial number of the camera
|
|
115
|
+
* arguments (for example, optional)
|
|
116
|
+
* `AcquisitionFrameRate`: change the framerate of the camera
|
|
117
|
+
* `ExposureTime`: exposure time in us
|
|
118
|
+
* `...` or any other camera supported parameter name and value
|
|
119
|
+
* `cameras/configure`
|
|
120
|
+
* as previous, but apply the configuration changes to every attached camera
|
|
121
|
+
* `camera/<serial>/read/<parameter_name>`
|
|
122
|
+
* change camera configuration
|
|
123
|
+
* `serial`: the serial number of the camera
|
|
124
|
+
* `parameter_name`: the name of a camera parameter, like that passed to `/configure`, e.g. `AcquisitionFrameRate`
|
|
125
|
+
* `cameras/read/<parameter_name>`
|
|
126
|
+
* as previous, but return values for all cameras
|
|
127
|
+
* `camera/<serial>/recording/start`
|
|
128
|
+
* start recording on the selected camera
|
|
129
|
+
* `serial`: the serial number of the camera
|
|
130
|
+
* arguments
|
|
131
|
+
* `filename` (optional): recording filename (excluding timestamp)
|
|
132
|
+
* `record_to_store` (optional)
|
|
133
|
+
* `True`
|
|
134
|
+
* `False`
|
|
135
|
+
* no argument provided: use configured defaults
|
|
136
|
+
* `codec` (optional): codec, or use configured default if omitted
|
|
137
|
+
* `duration` (optional): number of seconds to record for, or indefinately if omitted
|
|
138
|
+
* `metadata` (optional): a dictionary of metadata to save in the resulting video
|
|
139
|
+
* `codec_identifier` (optional): advanced use only
|
|
140
|
+
* `recording/start`
|
|
141
|
+
* as previous, but start recording on all cameras
|
|
142
|
+
* `camera/<serial>/recording/stop`
|
|
143
|
+
* stop recording on the selected camera
|
|
144
|
+
* `serial`: the serial number of the camera
|
|
145
|
+
* `recording/stop`
|
|
146
|
+
* as previous, but stop recording on all cameras
|
|
147
|
+
* `camera/<serial>/recordings`
|
|
148
|
+
* return a list of recordings
|
|
149
|
+
* `serial`: the serial number of the camera
|
|
150
|
+
* `recordings`
|
|
151
|
+
* as previous, but return recordings for all cameras
|
|
152
|
+
* `camera/<serial>/recordings/copy_all`
|
|
153
|
+
* copy (or move) all currently completed recordings to another location
|
|
154
|
+
* `serial`: the serial number of the camera
|
|
155
|
+
* arguments
|
|
156
|
+
* `location` (optional): local user path or if omitted, default configued location
|
|
157
|
+
* `delete_after` (optional, default=False): delete original recordings after successful copy
|
|
158
|
+
* `loopy_username` (optional)
|
|
159
|
+
* `loopy_url` (optional)
|
|
160
|
+
* `loopy_api_key` (optional)
|
|
161
|
+
* `loopy_import_base` (optional):
|
|
162
|
+
connection details of accesibly self hosted loopy instance for automatic
|
|
163
|
+
importing of videos into loopy after copy finishes
|
|
164
|
+
* `recordings/copy_all`
|
|
165
|
+
* as previous, but copy recordings from all cameras
|
|
166
|
+
* `camera/<serial>/recordings/export_all`
|
|
167
|
+
* export image stores to normal mp4 videos (or image stores)
|
|
168
|
+
* `serial`: the serial number of the camera
|
|
169
|
+
* arguments
|
|
170
|
+
* `to_store` (optional)
|
|
171
|
+
* `True`
|
|
172
|
+
* `False`
|
|
173
|
+
* no argument provided: use configured defaults
|
|
174
|
+
* `codec` (optional)
|
|
175
|
+
* `delete_after` (optional)
|
|
176
|
+
* `path` (optional)
|
|
177
|
+
* `recordings/export_all`
|
|
178
|
+
* as previous, but export image stores from all cameras
|
|
179
|
+
* `camera/<serial>/io/<name>/set`
|
|
180
|
+
* set named output associated with camera to provided value
|
|
181
|
+
* `serial`: the serial number of the camera
|
|
182
|
+
* `name`: the name of the configured output channel
|
|
183
|
+
* arguments
|
|
184
|
+
* `value`: the value to set on the output (backend dependent)
|
|
185
|
+
* `io/<name>/set`
|
|
186
|
+
* set named output to the provided value. in index or master mode in
|
|
187
|
+
a multiple camera setup, the named output channel must be on a
|
|
188
|
+
output device attached to the master or index node.
|
|
189
|
+
setup (or to the only attached camera in a single camera setup)
|
|
190
|
+
* `name`: the name of the configured output channel
|
|
191
|
+
* arguments
|
|
192
|
+
* `value`: the value to set on the output (backend dependent)
|
|
193
|
+
* `io/<io_serial>/<io_port>/set` (DEPRECATED)
|
|
194
|
+
* configure and set output to provided value
|
|
195
|
+
* `io_serial`: serial number of IO device
|
|
196
|
+
* `io_port`: port on device to set
|
|
197
|
+
* arguments
|
|
198
|
+
* `value`: continuous value to set OR
|
|
199
|
+
* `state`: 0 or 1 to turn on or off
|
|
200
|
+
* `io/log`
|
|
201
|
+
* send multiple arguements to be recorded in the imgstore extra_data - in addition to the current frame_number and time of the log message
|
|
202
|
+
* `camera/<serial>/experiment/message`
|
|
203
|
+
* send a user-facing status message from an experiment script to the web UI
|
|
204
|
+
for the specified camera
|
|
205
|
+
* `serial`: the serial number of the camera
|
|
206
|
+
* arguments
|
|
207
|
+
* `message`: the text to display. pass an empty string to clear the message
|
|
208
|
+
* `experiment/message`
|
|
209
|
+
* as previous, but in a single camera setup or to target the multicam
|
|
210
|
+
master/index UI
|
|
211
|
+
* `multicam/synchronize`
|
|
212
|
+
* synchronize the cameras (in a multiple camera setup)
|
|
213
|
+
* `multicam/connect_camera/<serial>`
|
|
214
|
+
* start the camera process for the camera with the provided serial
|
|
215
|
+
* `serial`: the serial number of the camera.
|
|
216
|
+
* passing the special value `__all` here connects all cameras
|
|
217
|
+
* `multicam/disconnect_camera/<serial>`
|
|
218
|
+
* stops the camera process for the camera with the provided serial
|
|
219
|
+
* `serial`: the serial number of the camera.
|
|
220
|
+
* passing the special value `__all` here disconnects all cameras
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
**Outputs and Toggling Values**
|
|
224
|
+
|
|
225
|
+
There exists a special value for (digital) outputs that results in the output state being
|
|
226
|
+
toggled (alternating) between minimum and maximum values. If you pass `value=+inf` then this means
|
|
227
|
+
start with a switch to maximum, then toggle between minimum and maximum subsequently. `value=-inf`
|
|
228
|
+
start with a switch to minimum, then toggle between maximum and minimum subsequently.
|
|
229
|
+
|
|
230
|
+
## Experiment Script Messages
|
|
231
|
+
|
|
232
|
+
Experiment scripts can send a short text message back to the Motif web UI using the
|
|
233
|
+
`send_message` convenience method (or the `experiment/message` endpoint). The message
|
|
234
|
+
appears in the Experiment Scripts section of the UI with a timestamp, and can be updated
|
|
235
|
+
at any time to show progress or status.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
from motifapi import MotifApi
|
|
239
|
+
|
|
240
|
+
api = MotifApi(IP_ADDRESS, API_KEY)
|
|
241
|
+
|
|
242
|
+
# send a message (appears in the multicam or single-cam UI)
|
|
243
|
+
api.send_message('Starting trial 1')
|
|
244
|
+
|
|
245
|
+
# send a message targeting a specific camera
|
|
246
|
+
api.send_message('Acquiring baseline', serial='FAKE0')
|
|
247
|
+
|
|
248
|
+
# clear the message
|
|
249
|
+
api.send_message('')
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Using `api.call` directly:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
# to the multicam / single-cam UI
|
|
256
|
+
api.call('experiment/message', message='Hello from script')
|
|
257
|
+
|
|
258
|
+
# to a specific camera's UI
|
|
259
|
+
api.call('camera/FAKE0/experiment/message', message='Running phase 2')
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Scheduling Function
|
|
263
|
+
|
|
264
|
+
Motif allows scheduling of almost all previously documented API operations.
|
|
265
|
+
Scheduling allows executing a certain operation as a defined time.
|
|
266
|
+
|
|
267
|
+
This allows for example, to schedule recordings and their subsequent copy
|
|
268
|
+
to storage to occur at specific times. Task scheduling re-uses [Cron syntax]()
|
|
269
|
+
with some extensions.
|
|
270
|
+
|
|
271
|
+
### Cron Syntax
|
|
272
|
+
|
|
273
|
+
Please refer to [here](README.cronex.md) for a complete specification and more examples.
|
|
274
|
+
|
|
275
|
+
Each scheduled operation is specified with a combination of six white-space
|
|
276
|
+
separated fields that dictate when the event should occur. In order, the fields
|
|
277
|
+
specify trigger times for the second, minute, hour, day of the month,
|
|
278
|
+
month and day of the week.
|
|
279
|
+
|
|
280
|
+
```text
|
|
281
|
+
.------------------ Second (0 - 59)
|
|
282
|
+
| .--------------- Minute (0 - 59)
|
|
283
|
+
| | .------------ Hour (0 - 23)
|
|
284
|
+
| | | .--------- Day of the month (1 - 31)
|
|
285
|
+
| | | | .------ Month (1 - 12) or Jan, Feb ... Dec
|
|
286
|
+
| | | | | .---- Day of the week (0 (Sun.; also 7) - 6 (Sat.))
|
|
287
|
+
| | | | | |
|
|
288
|
+
V V V V V V
|
|
289
|
+
* * * * * *
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Scheduling API Documentation
|
|
293
|
+
|
|
294
|
+
The API for scheduling tasks is very similar to that of other tasks - API endpoints are
|
|
295
|
+
prefixed with `schedule/VERB` may/should be passed additional optional or
|
|
296
|
+
compulsory arguments.
|
|
297
|
+
|
|
298
|
+
**Scheduling specific arguments**
|
|
299
|
+
|
|
300
|
+
* `task_name` (required): A unique short identifier for this task
|
|
301
|
+
* `cron_expression` (required): a cron specifier conforming to the cron syntax above
|
|
302
|
+
* `camera_relative` (optional, Motif 5 and above only)
|
|
303
|
+
* `True`: if true all absolute and relative dates are relative to the start of the recording
|
|
304
|
+
* `False` (default): all dates are absolute, and monotonic expressions are relative to 1-Jan-1970
|
|
305
|
+
|
|
306
|
+
**Camera Relative Schedules**
|
|
307
|
+
|
|
308
|
+
Please ensure you have read the [full documentation](README.cron.md) on cron triggers first. Often one needs to schedule events not in the conventional cron sense - relative to the date and time of the day, but instead wants to schedule operations relative to when a recording was started. If a task is scheduled with `camera_relative=True` then dates are interpreted differently. For example the expression
|
|
309
|
+
|
|
310
|
+
* `0 5 * ? * * *`
|
|
311
|
+
* with `camera_relative=False` means execute the task *at second :00 of minute :05 of every hour*
|
|
312
|
+
* e.g. Fri May 17 16:05:00 UTC 2019, Fri May 17 17:05:00 UTC 2019, Fri May 17 18:05:00 UTC 2019
|
|
313
|
+
* with `camera_relative=False` means execute the task *at second :00 of minute :05 of every hour* relative to when recording was started.
|
|
314
|
+
* e.g., for a recording started at Fri May 17 13:14:00 UTC 2019, the task would trigger at Fri May 17 13:19:00 UTC 2019, Fri May 17 14:19:00 UTC 2019, etc.
|
|
315
|
+
|
|
316
|
+
With monotonic triggers `7%7` or `%10` this can be confusing. Monotonic triggers execute *every unit of time*. For example the expression
|
|
317
|
+
|
|
318
|
+
* `0 * %2 ? * * *`
|
|
319
|
+
* with `camera_relative=False` means *execute every 2 hours starting at 00:00:00*
|
|
320
|
+
* e.g. Fri May 17 00:00:00 UTC 2019, Fri May 17 02:00:00 UTC 2019
|
|
321
|
+
* with `camera_relative=True` means *execute every 2 hours starting at the time of recording start*
|
|
322
|
+
* e.g., for a recording started at Fri May 17 13:14:00 UTC 2019, the task would trigger at Fri May 17 15:14:00 UTC 2019, Fri May 17 17:14:00 UTC 2019, etc.
|
|
323
|
+
|
|
324
|
+
**API (continued)**
|
|
325
|
+
|
|
326
|
+
* `schedule`
|
|
327
|
+
* list all scheduled tasks, return also including the current time on
|
|
328
|
+
the system
|
|
329
|
+
* `schedule/clear`
|
|
330
|
+
* delete all scheduled tasks
|
|
331
|
+
* `schedule/<task_name>/clear`
|
|
332
|
+
* delete the provided task
|
|
333
|
+
* `task_name`: the identifier of the task to clear
|
|
334
|
+
* `schedule/camera/<serial>/recording/start`
|
|
335
|
+
* schedule the start of recording on the selected camera
|
|
336
|
+
* `serial`: the serial number of the camera
|
|
337
|
+
* arguments
|
|
338
|
+
* scheduling specific (above), excluding `camera_relative`
|
|
339
|
+
* see `recording/start` (above)
|
|
340
|
+
* `schedule/recording/start`
|
|
341
|
+
* as previous, but schedule the start of recording on all cameras
|
|
342
|
+
* `schedule/camera/<serial>/recordings/copy_all`
|
|
343
|
+
* schedule copy (or move) of recordings
|
|
344
|
+
* `serial`: the serial number of the camera
|
|
345
|
+
* arguments
|
|
346
|
+
* scheduling specific (above), excluding `camera_relative`
|
|
347
|
+
* see `recordings/copy_all` (above)
|
|
348
|
+
* `schedule/recordings/copy_all`
|
|
349
|
+
* as previous, but schedule copy (or move) of recordings on all cameras
|
|
350
|
+
* `schedule/camera/<serial>/recordings/export_all`
|
|
351
|
+
* schedule export of recordings on the selected camera
|
|
352
|
+
* `serial`: the serial number of the camera
|
|
353
|
+
* arguments
|
|
354
|
+
* scheduling specific (above), excluding `camera_relative`
|
|
355
|
+
* see `recordings/export_all`
|
|
356
|
+
* `schedule/recordings/export_all`
|
|
357
|
+
* as previous, but schedule export of recordings on all cameras
|
|
358
|
+
* `schedule/camera/<serial>/configure/<name>`
|
|
359
|
+
* schedule the change of a camera configuration
|
|
360
|
+
* `serial`: the serial number of the camera
|
|
361
|
+
* `name`: the the name of the parameter to change (e.g. `ExposureTime`)
|
|
362
|
+
* arguments
|
|
363
|
+
* scheduling specific (above)
|
|
364
|
+
* `value` (required): the new value of the parameter
|
|
365
|
+
* `schedule/cameras/configure/<name>`
|
|
366
|
+
* as previous, but schedule the change of camera configuration on all cameras
|
|
367
|
+
* `schedule/camera/<serial>/io/<name>/set`
|
|
368
|
+
* schedule the setting of a named output, on an IO device connected to the
|
|
369
|
+
supplied camera, to a provided value
|
|
370
|
+
* `serial`: the serial number of the camera
|
|
371
|
+
* `name`: the name of the configured output channel
|
|
372
|
+
* arguments
|
|
373
|
+
* scheduling specific (above)
|
|
374
|
+
* `value` (required): the new value of the parameter
|
|
375
|
+
* `schedule/io/<name>/set`
|
|
376
|
+
* schedule the setting of the named output to the provided value.
|
|
377
|
+
in index or master mode in a multiple camera setup, the named output
|
|
378
|
+
channel must be on a output device attached to the master or index node.
|
|
379
|
+
* arguments
|
|
380
|
+
* scheduling specific (above)
|
|
381
|
+
* `value` (required): the new value of the parameter
|
|
382
|
+
|
|
383
|
+
## Scheduling Examples
|
|
384
|
+
|
|
385
|
+
To schedule a 30 minute recording as configured above to be executed every-hour-on-the-hour between
|
|
386
|
+
9am and 4pm, make the following API call
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
api.call('schedule/recording/start',
|
|
390
|
+
task_name='record_video',
|
|
391
|
+
cron_expression='0 6-16 * * * *',
|
|
392
|
+
duration=30*60)
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
To schedule events relative to the start of recording on a specific camera, consider the following examples.
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
import datetime
|
|
399
|
+
from motifapi import datetime_to_cron
|
|
400
|
+
|
|
401
|
+
camera_serial = '123456'
|
|
402
|
+
io_name = 'led'
|
|
403
|
+
|
|
404
|
+
# note the use of datetime_to_cron() which converts an absolute date/time to a non-repeating
|
|
405
|
+
# non-monotonic cron expression. Furthermore, note that because this should occur 17s relative
|
|
406
|
+
# to the start of recording, we pass a datetime initialized from 0 seconds.
|
|
407
|
+
|
|
408
|
+
api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
|
|
409
|
+
task_name='led_on_after_17s',
|
|
410
|
+
cron_expression=datetime_to_cron(datetime.datetime.fromtimestamp(0) + datetime.timedelta(seconds=17)),
|
|
411
|
+
camera_relative=True,
|
|
412
|
+
value=1.0)
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
monotonic expressions do not require the use of `datetime_to_cron` as the repeat regardless. For example the following expression flashes / toggles an LED every second
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
|
|
419
|
+
task_name='toggle_led_2s',
|
|
420
|
+
cron_expression='%2 * * ? * * *',
|
|
421
|
+
camera_relative=True,
|
|
422
|
+
value=float('+inf'))
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
neither to regular repeating cron expressions. Consider the following two scheduled operations that turn on an LED at `00:15:00` and `00:45:00` and turn it off at `00:00:00` and `00:30:00`.
|
|
426
|
+
|
|
427
|
+
```python
|
|
428
|
+
api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
|
|
429
|
+
task_name='turn_on_led',
|
|
430
|
+
cron_expression='0 15,45 * ? * * *',
|
|
431
|
+
camera_relative=True,
|
|
432
|
+
value=1.0)
|
|
433
|
+
api.call('schedule/camera/%s/io/%s/set' % (camera_serial, io_name),
|
|
434
|
+
task_name='turn_off_led',
|
|
435
|
+
cron_expression='0 0,30 * ? * * *',
|
|
436
|
+
camera_relative=True,
|
|
437
|
+
value=0.0)
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
note: this same sequence of operations could have likewise been described using a monotonic expression `0 15%15 * ? * * *` and 'toggling IO (see above)' `value=float(+inf)`.
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
## FAQ
|
|
444
|
+
|
|
445
|
+
* Q) What if Motif is running on another computer? how to I find the time on the system
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
now = api.call('schedule')['now']
|
|
449
|
+
dt = datetime.datetime.fromtimestamp(now)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
* Q) What is the easiest way to schedule something to happen 'some time from now'
|
|
453
|
+
|
|
454
|
+
```python
|
|
455
|
+
# following from the example above
|
|
456
|
+
import datetime
|
|
457
|
+
from motifapi import datetime_to_cron
|
|
458
|
+
future = dt + datetime.timedelta(seconds=30)
|
|
459
|
+
api.call('schedule/recording/start',
|
|
460
|
+
task_name='record_video_in_30s_time',
|
|
461
|
+
cron_expression=datetime_to_cron(future),
|
|
462
|
+
duration=30*60)
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
* Q) Cron expressions are confusing. Can you recommend ways to help understanding
|
|
466
|
+
* you can test and design cron expressions online using the following site (note: neither support monotonic expressions)
|
|
467
|
+
* [https://www.freeformatter.com/cron-expression-generator-quartz.html](https://www.freeformatter.com/cron-expression-generator-quartz.html)
|
|
468
|
+
* [https://cronexpressiondescriptor.azurewebsites.net/](https://cronexpressiondescriptor.azurewebsites.net/)
|
|
469
|
+
|
|
470
|
+
**See examples/scheduler.py for more information**
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
## Other Languages
|
|
474
|
+
|
|
475
|
+
The API is a pure REST api, which means any language / framework / environment with support for calling REST+JSON endpoints and handling their responses is supported.
|
|
476
|
+
|
|
477
|
+
Note: You must provide the HTTPS server certificate and authentication API key in a manner appropriate for your language / framework / environment.
|
|
478
|
+
|
|
479
|
+
### MATLAB
|
|
480
|
+
|
|
481
|
+
Because the Motif API is pure HTTP+JSON you can also use MATLAB to control the recording systems. Note however the following caveats
|
|
482
|
+
|
|
483
|
+
* Examples below are for the hypothetical IP address `10.11.12.13` and API_KEY `abcdef123456abcdef123456abcdef12`. Please replace with values appropriate to your system
|
|
484
|
+
* On windows, you can find the API key in the config file (link in Start menu), on Linux by executing `recnode-apikey` on the PC
|
|
485
|
+
* `server.crt` must be the full path to the `server.crt` file which you should [download](https://raw.githubusercontent.com/loopbio/python-motifapi/master/motifapi/server.crt) from this repository.
|
|
486
|
+
* Not all endpoints are `POST`, although most are. `GET` endpoints are read using `webread`, `POST`, `PATCH`, `DELETE` using `webwrite`. The endpoint type is documented in [api.py](http://github.com/loopbio/python-motifapi/blob/master/motifapi/api.py).
|
|
487
|
+
|
|
488
|
+
**Checking Status**
|
|
489
|
+
|
|
490
|
+
```matlab
|
|
491
|
+
% example showing how to list connected cameras from MATLAB
|
|
492
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
493
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'})
|
|
494
|
+
# list the connected cameras
|
|
495
|
+
webread('https://10.11.12.13:6083/api/1/cameras', o)
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
**Turning on an Output**
|
|
499
|
+
|
|
500
|
+
```matlab
|
|
501
|
+
% example showing how to set an output using a POST request
|
|
502
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
503
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
|
|
504
|
+
'MediaType', 'application/json')
|
|
505
|
+
arguments = struct('value',0.3);
|
|
506
|
+
webwrite('https://10.11.12.13:6083/api/1/io/led/set',arguments,o)
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
**Starting Recording**
|
|
510
|
+
|
|
511
|
+
```matlab
|
|
512
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
513
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
|
|
514
|
+
'MediaType', 'application/json')
|
|
515
|
+
arguments = struct('codec','h264');
|
|
516
|
+
webwrite('https://10.11.12.13:6083/api/1/recording/start',arguments,o)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
**Stopping Recording**
|
|
520
|
+
|
|
521
|
+
```matlab
|
|
522
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
523
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
|
|
524
|
+
'MediaType', 'application/json')
|
|
525
|
+
arguments = struct('codec','h264');
|
|
526
|
+
webwrite('https://10.11.12.13:6083/api/1/recording/stop',arguments,o)
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
**Checking Status**
|
|
530
|
+
|
|
531
|
+
```matlab
|
|
532
|
+
% example showing how to list connected cameras from MATLAB
|
|
533
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
534
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'})
|
|
535
|
+
# list the connected cameras
|
|
536
|
+
webread('https://10.11.12.13:6083/api/1/cameras', o)
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
**Turning on an Output**
|
|
540
|
+
|
|
541
|
+
```matlab
|
|
542
|
+
% example showing how to set an output using a POST request
|
|
543
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
544
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
|
|
545
|
+
'MediaType', 'application/json')
|
|
546
|
+
arguments = struct('value',0.3);
|
|
547
|
+
webwrite('https://10.11.12.13:6083/api/1/io/led/set',arguments,o)
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
**Starting Recording**
|
|
551
|
+
|
|
552
|
+
```matlab
|
|
553
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
554
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
|
|
555
|
+
'MediaType', 'application/json')
|
|
556
|
+
arguments = struct('codec','h264');
|
|
557
|
+
webwrite('https://10.11.12.13:6083/api/1/recording/start',arguments,o)
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
**Stopping Recording**
|
|
561
|
+
|
|
562
|
+
```matlab
|
|
563
|
+
o = weboptions('CertificateFilename', 'server.crt', ...
|
|
564
|
+
'HeaderFields', {'X-Api-Key', 'abcdef123456abcdef123456abcdef12'}, ...
|
|
565
|
+
'MediaType', 'application/json')
|
|
566
|
+
arguments = struct('codec','h264');
|
|
567
|
+
webwrite('https://10.11.12.13:6083/api/1/recording/stop',arguments,o)
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
<!---motifcutstart--->
|
|
571
|
+
|
|
572
|
+
## Realtime Streaming
|
|
573
|
+
|
|
574
|
+
Motif can, with very low latency (<1ms), stream realtime images from the camera, without interfering with
|
|
575
|
+
the recording, compression, or any other motif functions. This allows developing *out of process* realtime
|
|
576
|
+
image processing algorithms for closed loop experiments. Such experiments could for example then provide
|
|
577
|
+
stimulus to the animal using either Motif connected and configured outputs, or other user provided methods.
|
|
578
|
+
|
|
579
|
+
Per default streaming is limited to localhost (so such scripts must run on the same PC as Motif), however
|
|
580
|
+
Motif can be configured to stream also to other network locations if you are aware of the latency
|
|
581
|
+
implications and have sufficient network infrastructure.
|
|
582
|
+
|
|
583
|
+
### Streaming Images
|
|
584
|
+
|
|
585
|
+
A realtime image stream can be established as follows (after constructing the `api` object with the correct IP address and API_KEY as indicated above
|
|
586
|
+
|
|
587
|
+
```python
|
|
588
|
+
# small example using opencv to show the image in realtime, outside of motif
|
|
589
|
+
|
|
590
|
+
stream = api.get_stream(stream_type=MotifApi.STREAM_TYPE_IMAGE)
|
|
591
|
+
if stream is not None:
|
|
592
|
+
while True:
|
|
593
|
+
I, md = stream.get_next_image()
|
|
594
|
+
cv2.imshow('live', I)
|
|
595
|
+
cv2.waitKey(1)
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Streaming State
|
|
599
|
+
|
|
600
|
+
If you have other more custom data acquisition needs not supported by Motif IO / DAQ support, and want to subsequently
|
|
601
|
+
integrate or synchronize this custom data with the motif imagestore `frame_number`s and `frame_time`s then you can use the realtime state streaming. Motif publishes to this stream, at low latency (<<1ms), the current `frame_number`, `frame_time`, and other information. A realtime state stream is established as follows
|
|
602
|
+
|
|
603
|
+
```python
|
|
604
|
+
stream = api.get_stream(stream_type=MotifApi.STREAM_TYPE_STATE)
|
|
605
|
+
if stream is not None:
|
|
606
|
+
while True:
|
|
607
|
+
print stream.get_next_state()
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
<!---motifcutend--->
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import json
|
|
3
|
+
import ssl
|
|
4
|
+
import socket
|
|
5
|
+
import os.path
|
|
6
|
+
import subprocess
|
|
7
|
+
import logging
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
import http.client
|
|
11
|
+
|
|
12
|
+
DEFAULT_HTTP_TIMEOUT = 10 # seconds
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# http://code.activestate.com/recipes/577548-https-httplib-client-connection-with-certificate-v/
|
|
16
|
+
# http://stackoverflow.com/questions/1875052/using-paired-certificates-with-urllib2
|
|
17
|
+
|
|
18
|
+
class HTTPSClientAuthHandler(urllib.request.HTTPSHandler):
|
|
19
|
+
"""
|
|
20
|
+
Allows sending a client certificate with the HTTPS connection.
|
|
21
|
+
This version also validates the peer (server) certificate.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, key=None, cert=None, ca_certs=None):
|
|
25
|
+
self.ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
26
|
+
self.ctx.check_hostname = False
|
|
27
|
+
if ca_certs:
|
|
28
|
+
self.ctx.verify_mode = ssl.CERT_REQUIRED
|
|
29
|
+
self.ctx.load_verify_locations(ca_certs)
|
|
30
|
+
else:
|
|
31
|
+
self.ctx.verify_mode = ssl.CERT_NONE
|
|
32
|
+
if cert:
|
|
33
|
+
self.ctx.load_cert_chain(cert, key)
|
|
34
|
+
urllib.request.HTTPSHandler.__init__(self, context=self.ctx)
|
|
35
|
+
|
|
36
|
+
def https_open(self, req):
|
|
37
|
+
return self.do_open(self.get_connection, req)
|
|
38
|
+
|
|
39
|
+
def get_connection(self, host, timeout=DEFAULT_HTTP_TIMEOUT):
|
|
40
|
+
return HTTPSConnection(host, timeout=timeout, context=self.ctx)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HTTPSConnection(http.client.HTTPSConnection):
|
|
44
|
+
"""
|
|
45
|
+
Overridden to allow peer certificate validation using an SSLContext.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, host, **kwargs):
|
|
49
|
+
self.ctx = kwargs.pop('context', None)
|
|
50
|
+
http.client.HTTPSConnection.__init__(self, host, **kwargs)
|
|
51
|
+
|
|
52
|
+
def connect(self):
|
|
53
|
+
sock = socket.create_connection((self.host, self.port), self.timeout)
|
|
54
|
+
|
|
55
|
+
if self._tunnel_host:
|
|
56
|
+
self.sock = sock
|
|
57
|
+
self._tunnel()
|
|
58
|
+
|
|
59
|
+
if self.ctx:
|
|
60
|
+
self.sock = self.ctx.wrap_socket(sock, server_hostname=self.host)
|
|
61
|
+
else:
|
|
62
|
+
self.sock = sock
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MethodRequest(urllib.request.Request):
|
|
66
|
+
# See: https://gist.github.com/logic/2715756
|
|
67
|
+
|
|
68
|
+
def __init__(self, *args, **kwargs):
|
|
69
|
+
if 'method' in kwargs:
|
|
70
|
+
self._method = kwargs['method']
|
|
71
|
+
del kwargs['method']
|
|
72
|
+
else:
|
|
73
|
+
self._method = None
|
|
74
|
+
return urllib.request.Request.__init__(self, *args, **kwargs)
|
|
75
|
+
|
|
76
|
+
def get_method(self, *args, **kwargs):
|
|
77
|
+
return self._method if self._method is not None else urllib.request.Request.get_method(self, *args, **kwargs)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MotifError(Exception):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MotifApiError(MotifError):
|
|
85
|
+
def __init__(self, message, code):
|
|
86
|
+
self.code = code
|
|
87
|
+
Exception.__init__(self, message)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MotifApi(object):
|
|
91
|
+
|
|
92
|
+
STREAM_TYPE_IMAGE = 1
|
|
93
|
+
STREAM_TYPE_STATE = 2
|
|
94
|
+
|
|
95
|
+
API = {r'version$': 'GET',
|
|
96
|
+
r'cameras$': 'GET',
|
|
97
|
+
r'cameras/configure$': 'PATCH',
|
|
98
|
+
r'cameras/read/(?P<name>[^\s /]+)$': 'GET',
|
|
99
|
+
r'camera/(?P<serial>[^\s /]+)$': 'GET',
|
|
100
|
+
r'camera/(?P<serial>[^\s /]+)/configure$': 'PATCH',
|
|
101
|
+
r'camera/(?P<serial>[^\s /]+)/read/(?P<name>[^\s /]+)$': 'GET',
|
|
102
|
+
r'camera/(?P<serial>[^\s /]+)/recording/start$': 'POST',
|
|
103
|
+
r'camera/(?P<serial>[^\s /]+)/recording/stop$': 'POST',
|
|
104
|
+
r'camera/(?P<serial>[^\s /]+)/recordings$': 'GET',
|
|
105
|
+
r'camera/(?P<serial>[^\s /]+)/recordings/copy_all$': 'POST',
|
|
106
|
+
r'camera/(?P<serial>[^\s /]+)/recordings/export_all$': 'POST',
|
|
107
|
+
r'camera/(?P<serial>[^\s /]+)/io/(?P<name>[^\s /]+)/set': 'POST',
|
|
108
|
+
r'camera/(?P<serial>[^\s /]+)/io/log': 'POST',
|
|
109
|
+
r'camera/(?P<serial>[^\s /]+)/io/read': 'GET',
|
|
110
|
+
r'recording/start$': 'POST',
|
|
111
|
+
r'recording/stop$': 'POST',
|
|
112
|
+
r'recordings$': 'GET',
|
|
113
|
+
r'recordings/copy_all$': 'POST',
|
|
114
|
+
r'recordings/export_all$': 'POST',
|
|
115
|
+
r'schedule$': 'GET',
|
|
116
|
+
r'schedule/clear$': 'POST',
|
|
117
|
+
r'schedule/(?P<identifier>[^\s /]+)/clear$': 'DELETE',
|
|
118
|
+
r'schedule/recording/start$': 'POST',
|
|
119
|
+
r'schedule/camera/(?P<serial>[^\s /]+)/recording/start$': 'POST',
|
|
120
|
+
r'schedule/recordings/copy_all': 'POST',
|
|
121
|
+
r'schedule/camera/(?P<serial>[^\s /]+)/recordings/copy_all$': 'POST',
|
|
122
|
+
r'schedule/recordings/export_all': 'POST',
|
|
123
|
+
r'schedule/camera/(?P<serial>[^\s /]+)/recordings/export_all$': 'POST',
|
|
124
|
+
r'schedule/io/(?P<name>[^\s /]+)/set': 'POST',
|
|
125
|
+
r'schedule/camera/(?P<serial>[^\s /]+)/io/(?P<name>[^\s /]+)/set': 'POST',
|
|
126
|
+
r'schedule/cameras/configure/(?P<name>[^\s /]+)': 'POST',
|
|
127
|
+
r'schedule/camera/(?P<serial>[^\s /]+)/configure/(?P<name>[^\s /]+)': 'POST',
|
|
128
|
+
r'io/(?P<io_serial>[^\s /]+)/(?P<io_port>[^\s /]+)/set': 'POST',
|
|
129
|
+
r'io/(?P<name>[^\s /]+)/set': 'POST',
|
|
130
|
+
r'io/log': 'POST',
|
|
131
|
+
r'io/read': 'GET',
|
|
132
|
+
r'camera/(?P<serial>[^\s /]+)/experiment/message$': 'POST',
|
|
133
|
+
r'experiment/message$': 'POST',
|
|
134
|
+
r'multicam/synchronize': 'POST',
|
|
135
|
+
r'multicam/connect_camera/(?P<serial>[^\s /]+)': 'POST',
|
|
136
|
+
r'multicam/disconnect_camera/(?P<serial>[^\s /]+)': 'POST',
|
|
137
|
+
r'multicam/connect_all': 'POST',
|
|
138
|
+
r'multicam/disconnect_all': 'POST',
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def __init__(self, host=None, api_key=None, port=None, ca_cert=None, api_version=1):
|
|
142
|
+
self._log = logging.getLogger('motifapi')
|
|
143
|
+
|
|
144
|
+
if port is None:
|
|
145
|
+
try:
|
|
146
|
+
port = int(os.environ.get('MOTIF_PORT', 6083))
|
|
147
|
+
except:
|
|
148
|
+
port = 6083
|
|
149
|
+
else:
|
|
150
|
+
port = int(port)
|
|
151
|
+
|
|
152
|
+
if host is None:
|
|
153
|
+
try:
|
|
154
|
+
host = os.environ['MOTIF_HOST']
|
|
155
|
+
self._log.debug('took host from environment')
|
|
156
|
+
except KeyError:
|
|
157
|
+
pass
|
|
158
|
+
if not host:
|
|
159
|
+
host = '127.0.0.1'
|
|
160
|
+
|
|
161
|
+
if api_key is None:
|
|
162
|
+
try:
|
|
163
|
+
api_key = os.environ['MOTIF_API_KEY']
|
|
164
|
+
self._log.debug('took api_key from environment')
|
|
165
|
+
except KeyError:
|
|
166
|
+
pass
|
|
167
|
+
if not api_key:
|
|
168
|
+
try:
|
|
169
|
+
api_key = subprocess.check_output(['recnode-apikey']).strip()
|
|
170
|
+
self._log.debug('took api-key from recnode-apikey subprocess')
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
if not api_key:
|
|
175
|
+
raise ValueError('API key must be specified')
|
|
176
|
+
|
|
177
|
+
if ca_cert is None:
|
|
178
|
+
ca_cert = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'server.crt')
|
|
179
|
+
|
|
180
|
+
if not os.path.exists(ca_cert):
|
|
181
|
+
raise ValueError('could not find certificate: %s' % ca_cert)
|
|
182
|
+
|
|
183
|
+
self._prefix = 'api/%d/' % api_version
|
|
184
|
+
|
|
185
|
+
client_cert_key = None
|
|
186
|
+
client_cert_pem = None # file path
|
|
187
|
+
handler = HTTPSClientAuthHandler(
|
|
188
|
+
key=client_cert_key,
|
|
189
|
+
cert=client_cert_pem,
|
|
190
|
+
ca_certs=ca_cert)
|
|
191
|
+
self._http = urllib.request.build_opener(handler)
|
|
192
|
+
|
|
193
|
+
self._host = host
|
|
194
|
+
self._api_key = api_key
|
|
195
|
+
self._port = port
|
|
196
|
+
|
|
197
|
+
def _build_request(self, endpoint, data=None, method='GET'):
|
|
198
|
+
if endpoint[0] == '/':
|
|
199
|
+
endpoint = endpoint[1:]
|
|
200
|
+
|
|
201
|
+
if endpoint != 'version':
|
|
202
|
+
endpoint = self._prefix + endpoint
|
|
203
|
+
|
|
204
|
+
if data is not None:
|
|
205
|
+
try:
|
|
206
|
+
data = json.dumps(data).encode("utf-8")
|
|
207
|
+
except TypeError:
|
|
208
|
+
raise ValueError('Arguments must be JSON serializable (they were %r)' % (data,))
|
|
209
|
+
|
|
210
|
+
url = 'https://%s:%s/%s' % (self._host, self._port, endpoint)
|
|
211
|
+
|
|
212
|
+
req = MethodRequest(url, data, method=method)
|
|
213
|
+
req.add_header('X-Api-Key', self._api_key)
|
|
214
|
+
req.add_header('Content-Type', 'application/json')
|
|
215
|
+
|
|
216
|
+
self._log.debug('%s %s (%d bytes %s)' % (method,
|
|
217
|
+
url,
|
|
218
|
+
0 if data is None else len(data),
|
|
219
|
+
type(data)))
|
|
220
|
+
|
|
221
|
+
return req
|
|
222
|
+
|
|
223
|
+
def _call(self, req):
|
|
224
|
+
try:
|
|
225
|
+
resp = self._http.open(req)
|
|
226
|
+
data = resp.read()
|
|
227
|
+
resp.close()
|
|
228
|
+
return data
|
|
229
|
+
except urllib.error.HTTPError as e:
|
|
230
|
+
try:
|
|
231
|
+
raw = e.read()
|
|
232
|
+
err = json.loads(raw.decode('utf-8'))
|
|
233
|
+
exc = MotifApiError(err['error'], err['status_code'])
|
|
234
|
+
except Exception:
|
|
235
|
+
raise ValueError('unknown API error')
|
|
236
|
+
raise exc
|
|
237
|
+
except urllib.error.URLError:
|
|
238
|
+
raise MotifError('motif not running or reachable')
|
|
239
|
+
|
|
240
|
+
def call(self, endpoint, method=None, data=None, **kwargs):
|
|
241
|
+
meth = ep = None
|
|
242
|
+
for _ep, _meth in self.API.items():
|
|
243
|
+
if re.match(_ep, endpoint):
|
|
244
|
+
meth = _meth
|
|
245
|
+
ep = _ep
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
if meth is None:
|
|
249
|
+
raise ValueError("unknown endpoint '%s' (are you missing/adding '/')" % endpoint)
|
|
250
|
+
|
|
251
|
+
req = self._build_request(endpoint,
|
|
252
|
+
data=kwargs or None,
|
|
253
|
+
method=meth)
|
|
254
|
+
out = self._call(req)
|
|
255
|
+
if out:
|
|
256
|
+
try:
|
|
257
|
+
return json.loads(out.decode('utf-8'))
|
|
258
|
+
except ValueError as e:
|
|
259
|
+
raise ValueError('Invalid JSON response: %s' % e.message)
|
|
260
|
+
else:
|
|
261
|
+
return {}
|
|
262
|
+
|
|
263
|
+
def is_recording(self, serial):
|
|
264
|
+
r = self.call('camera/%s' % serial)
|
|
265
|
+
if r['camera_info'].get('filename'):
|
|
266
|
+
return True
|
|
267
|
+
elif r['camera_info'].get('status') == 'pending':
|
|
268
|
+
return True
|
|
269
|
+
return False
|
|
270
|
+
|
|
271
|
+
def is_copying(self, serial):
|
|
272
|
+
r = self.call('camera/%s' % serial)
|
|
273
|
+
return r['playback_info']['status'] == 'copying'
|
|
274
|
+
|
|
275
|
+
def is_exporting(self, serial):
|
|
276
|
+
r = self.call('camera/%s' % serial)
|
|
277
|
+
status = r['playback_info']['status']
|
|
278
|
+
return status.startswith('export') and ('finished' not in status)
|
|
279
|
+
|
|
280
|
+
def send_message(self, message, serial=None):
|
|
281
|
+
if serial:
|
|
282
|
+
return self.call('camera/%s/experiment/message' % serial, message=message)
|
|
283
|
+
return self.call('experiment/message', message=message)
|
|
284
|
+
|
|
285
|
+
def get_stream(self, serial=None, stream_type=STREAM_TYPE_IMAGE, force_host=None):
|
|
286
|
+
from .stream import ImageStreamer, StateStreamer
|
|
287
|
+
|
|
288
|
+
if serial is None:
|
|
289
|
+
try:
|
|
290
|
+
# get the first camera
|
|
291
|
+
for c in self.call('cameras').get('cameras', []):
|
|
292
|
+
serial = c['serial']
|
|
293
|
+
break
|
|
294
|
+
except (urllib.error.URLError, MotifApiError):
|
|
295
|
+
raise MotifError('motif not running or reachable')
|
|
296
|
+
|
|
297
|
+
if serial is None:
|
|
298
|
+
raise MotifError('no cameras connected or running')
|
|
299
|
+
|
|
300
|
+
def _get_host_port(_stream_name):
|
|
301
|
+
_stat = self.call('camera/%s' % serial)
|
|
302
|
+
_host = _stat['camera_info']['stream'][_stream_name]['host']
|
|
303
|
+
_port = int(_stat['camera_info']['stream'][_stream_name]['port'])
|
|
304
|
+
|
|
305
|
+
if force_host is not None:
|
|
306
|
+
_host = force_host
|
|
307
|
+
elif (_host == '0.0.0.0') and (self._host != '0.0.0.0'):
|
|
308
|
+
_host = self._host
|
|
309
|
+
|
|
310
|
+
return _host, _port
|
|
311
|
+
|
|
312
|
+
if stream_type in (MotifApi.STREAM_TYPE_IMAGE, MotifApi.STREAM_TYPE_STATE):
|
|
313
|
+
try:
|
|
314
|
+
if stream_type == MotifApi.STREAM_TYPE_IMAGE:
|
|
315
|
+
host, port = _get_host_port('image')
|
|
316
|
+
return ImageStreamer(host, port)
|
|
317
|
+
else:
|
|
318
|
+
host, port = _get_host_port('state')
|
|
319
|
+
return StateStreamer(host, port)
|
|
320
|
+
except (urllib.error.URLError, MotifApiError):
|
|
321
|
+
raise MotifError('camera with serial %s not found or running' % serial)
|
|
322
|
+
except KeyError:
|
|
323
|
+
raise MotifError('realtime stream not enabled on camera')
|
|
324
|
+
else:
|
|
325
|
+
raise ValueError('unknown stream type')
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-----BEGIN CERTIFICATE-----
|
|
2
|
+
MIIDGzCCAgOgAwIBAgIJAOjl0YTg0v1KMA0GCSqGSIb3DQEBCwUAMCQxCzAJBgNV
|
|
3
|
+
BAYTAkFVMRUwEwYDVQQKDAxsb29wYmlvIGdtYmgwHhcNMTcxMDI4MTM1MDQzWhcN
|
|
4
|
+
MjcxMDI2MTM1MDQzWjAkMQswCQYDVQQGEwJBVTEVMBMGA1UECgwMbG9vcGJpbyBn
|
|
5
|
+
bWJoMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuBDT2rvtlC3z330H
|
|
6
|
+
p5Zl1ilA4gvSM7Zlvpwt+kQETb7rCS49+oPaAa89tDEbwrk3rDzxnGG2+YL73h07
|
|
7
|
+
YpTlJk/7WkciGgR7K83CwEiSVUUZsmxfXH/t0V5km4d+Ul7lRWoRI9BcnWu9Mvwr
|
|
8
|
+
572NQJf0cWs0FJu5qYmY0nI7+EjN/qB7OFYYTYSJZQYwoRZzdGFNWCiTkxTBOQc/
|
|
9
|
+
1dyPzmtMbRQONYryMqMHKLUeG4aEYwCsm2XNSTHn7JCxc/dHc8BnBMkAOqNp+XP+
|
|
10
|
+
n4blQ6e4C4e4MWEk/yrv0p8u0m6NIoBvI/nZDhgvdIQTFrj+eIOSCPCtxzAQEnDd
|
|
11
|
+
f9FEiQIDAQABo1AwTjAdBgNVHQ4EFgQU1kn+nli9zgqxumR2zgMFS5WljhgwHwYD
|
|
12
|
+
VR0jBBgwFoAU1kn+nli9zgqxumR2zgMFS5WljhgwDAYDVR0TBAUwAwEB/zANBgkq
|
|
13
|
+
hkiG9w0BAQsFAAOCAQEAdr76akM25qXFURAgWszcjJAaaimp9RRUaXwO5UxVegCR
|
|
14
|
+
x1FCZyfm/TIWDgfKjlPFiPgCS3WBL0gLWummZhJ5YFwd0lI34H9KXl9JgKidyNrH
|
|
15
|
+
aDHBj4On0eu84f6nMxqFPFDDscJLlMvmfBYRFq/7QryU/LuWeSWhn/oQs4vakoRH
|
|
16
|
+
aJxzot67YvH9N8qxX/6Nc2TeEAz4X5AyEYe46mAc99rRROWjnYD39dfmWzv/5Cge
|
|
17
|
+
42YwNxtba8njGyPnCb+/kKerc+jXNBoopBGECBsMyGx3p5cVCnz//NhgvWmvquEU
|
|
18
|
+
msK/FxvWDIB5Y/KEfeaghB4D4hgy3IF9Z6T9ZBsLsg==
|
|
19
|
+
-----END CERTIFICATE-----
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import json
|
|
3
|
+
import threading
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
import zmq
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if sys.version_info > (3,):
|
|
11
|
+
buffer = memoryview
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
LOG = logging.getLogger('motifapi.stream')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def recv_array(socket, flags=0, copy=True, track=False):
|
|
18
|
+
md = socket.recv_json(flags=flags)
|
|
19
|
+
msg = socket.recv(flags=flags, copy=copy, track=track)
|
|
20
|
+
# note: I don't actually think this is necessary, in PY2 this is type<str> and np.frombuffer(str) does
|
|
21
|
+
# the right thing, in PY3 this is type<bytes> which supports buffer protocol
|
|
22
|
+
buf = buffer(msg)
|
|
23
|
+
A = np.frombuffer(buf, dtype=md.pop('dtype'))
|
|
24
|
+
return A.reshape(md.pop('shape')), md
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ImageStreamer(object):
|
|
28
|
+
|
|
29
|
+
stream = None
|
|
30
|
+
|
|
31
|
+
def __init__(self, host, port):
|
|
32
|
+
ctx = zmq.Context()
|
|
33
|
+
address = "tcp://%s:%d" % (host, port)
|
|
34
|
+
LOG.debug('image stream connecting to: %s' % address)
|
|
35
|
+
sock = ctx.socket(zmq.PULL)
|
|
36
|
+
sock.connect(address)
|
|
37
|
+
self.stream = sock
|
|
38
|
+
|
|
39
|
+
def get_next_image(self, block=True, copy=True):
|
|
40
|
+
while True:
|
|
41
|
+
while self.stream.poll(0, zmq.POLLIN):
|
|
42
|
+
return recv_array(self.stream, copy=copy)
|
|
43
|
+
if not block:
|
|
44
|
+
return None, None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class StateStreamer(object):
|
|
48
|
+
|
|
49
|
+
def __init__(self, host, port, channel='j'):
|
|
50
|
+
ctx = zmq.Context()
|
|
51
|
+
address = "tcp://%s:%d" % (host, port)
|
|
52
|
+
LOG.debug('state stream connecting to: %s' % address)
|
|
53
|
+
sock = ctx.socket(zmq.SUB)
|
|
54
|
+
sock.connect(address)
|
|
55
|
+
sock.setsockopt(zmq.LINGER, 0)
|
|
56
|
+
if sys.version_info > (3,):
|
|
57
|
+
sock.setsockopt_string(zmq.SUBSCRIBE, channel)
|
|
58
|
+
else:
|
|
59
|
+
sock.setsockopt(zmq.SUBSCRIBE, channel)
|
|
60
|
+
self.stream = sock
|
|
61
|
+
|
|
62
|
+
def get_next_state(self, block=True):
|
|
63
|
+
while True:
|
|
64
|
+
_, msg = self.stream.recv_multipart()
|
|
65
|
+
return json.loads(msg)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class StateMirror(threading.Thread):
|
|
69
|
+
|
|
70
|
+
daemon = True
|
|
71
|
+
|
|
72
|
+
def __init__(self, streamer):
|
|
73
|
+
assert isinstance(streamer, StateStreamer)
|
|
74
|
+
threading.Thread.__init__(self)
|
|
75
|
+
self._lock = threading.Lock()
|
|
76
|
+
self._state = {}
|
|
77
|
+
self._streamer = streamer
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def is_recording(self):
|
|
81
|
+
return self._state.get('is_recording', False)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def frame_number(self):
|
|
85
|
+
return self._state.get('frame_number', None)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def frame_time(self):
|
|
89
|
+
return self._state.get('frame_time', None)
|
|
90
|
+
|
|
91
|
+
def get_state(self, **extra):
|
|
92
|
+
with self._lock:
|
|
93
|
+
extra.update(self._state)
|
|
94
|
+
return extra
|
|
95
|
+
|
|
96
|
+
def run(self):
|
|
97
|
+
while True:
|
|
98
|
+
_, msg = self._streamer.stream.recv_multipart()
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._state.update(json.loads(msg))
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_experiment_metadata():
|
|
6
|
+
try:
|
|
7
|
+
mdf = os.environ['MOTIF_METADATA_JSON_PATH']
|
|
8
|
+
with open(mdf, 'r') as f:
|
|
9
|
+
return json.load(f)
|
|
10
|
+
except KeyError:
|
|
11
|
+
# not defined
|
|
12
|
+
pass
|
|
13
|
+
except Exception as exc:
|
|
14
|
+
print("Error parsing metadata file", exc)
|
|
15
|
+
|
|
16
|
+
return {}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: motifapi
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python interface to Motif recording systems
|
|
5
|
+
Author: John Stowers
|
|
6
|
+
Author-email: john@loopbio.com
|
|
7
|
+
License: BSD
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.6
|
|
11
|
+
Dynamic: author
|
|
12
|
+
Dynamic: author-email
|
|
13
|
+
Dynamic: classifier
|
|
14
|
+
Dynamic: description
|
|
15
|
+
Dynamic: license
|
|
16
|
+
Dynamic: requires-python
|
|
17
|
+
Dynamic: summary
|
|
18
|
+
|
|
19
|
+
Python interface to Motif recording systems
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
setup.py
|
|
4
|
+
motifapi/__init__.py
|
|
5
|
+
motifapi/api.py
|
|
6
|
+
motifapi/schedule.py
|
|
7
|
+
motifapi/server.crt
|
|
8
|
+
motifapi/stream.py
|
|
9
|
+
motifapi/util.py
|
|
10
|
+
motifapi.egg-info/PKG-INFO
|
|
11
|
+
motifapi.egg-info/SOURCES.txt
|
|
12
|
+
motifapi.egg-info/dependency_links.txt
|
|
13
|
+
motifapi.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
motifapi
|
motifapi-0.2.0/setup.cfg
ADDED
motifapi-0.2.0/setup.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
from setuptools import setup
|
|
4
|
+
|
|
5
|
+
setup(
|
|
6
|
+
name='motifapi',
|
|
7
|
+
license='BSD',
|
|
8
|
+
description='Python interface to Motif recording systems',
|
|
9
|
+
long_description='Python interface to Motif recording systems',
|
|
10
|
+
version='0.2.0',
|
|
11
|
+
author='John Stowers',
|
|
12
|
+
author_email='john@loopbio.com',
|
|
13
|
+
packages=['motifapi'],
|
|
14
|
+
python_requires='>=3.6',
|
|
15
|
+
classifiers=[
|
|
16
|
+
'Programming Language :: Python :: 3',
|
|
17
|
+
'Programming Language :: Python :: 3 :: Only',
|
|
18
|
+
],
|
|
19
|
+
include_package_data=True,
|
|
20
|
+
)
|
|
21
|
+
|