pytrms 0.9.0__tar.gz → 0.9.1__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.
- {pytrms-0.9.0 → pytrms-0.9.1}/PKG-INFO +2 -2
- {pytrms-0.9.0 → pytrms-0.9.1}/pyproject.toml +2 -2
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/__init__.py +1 -2
- pytrms-0.9.1/pytrms/clients/db_api.py +178 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/clients/ioniclient.py +5 -21
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/clients/modbus.py +44 -11
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/clients/mqtt.py +34 -14
- pytrms-0.9.1/pytrms/clients/ssevent.py +77 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/compose/composition.py +118 -23
- pytrms-0.9.1/pytrms/helpers.py +120 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/peaktable.py +7 -5
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/readers/ionitof_reader.py +1 -10
- pytrms-0.9.0/pytrms/clients/db_api.py +0 -186
- pytrms-0.9.0/pytrms/clients/dirigent.py +0 -169
- pytrms-0.9.0/pytrms/clients/ssevent.py +0 -82
- pytrms-0.9.0/pytrms/helpers.py +0 -6
- pytrms-0.9.0/pytrms/tracebuffer.py +0 -108
- {pytrms-0.9.0 → pytrms-0.9.1}/LICENSE +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/_base/__init__.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/_base/ioniclient.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/_base/mqttclient.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/_version.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/clients/__init__.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/compose/__init__.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/data/IoniTofPrefs.ini +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/data/ParaIDs.csv +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/instrument.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/measurement.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/plotting/__init__.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/plotting/plotting.py +0 -0
- {pytrms-0.9.0 → pytrms-0.9.1}/pytrms/readers/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pytrms
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS).
|
|
5
5
|
License: GPL-2.0
|
|
6
6
|
Author: Moritz Koenemann
|
|
@@ -15,5 +15,5 @@ Requires-Dist: h5py (>=3.12.1,<4.0.0)
|
|
|
15
15
|
Requires-Dist: matplotlib (>=3.9.2,<4.0.0)
|
|
16
16
|
Requires-Dist: paho-mqtt (>=1.6.1,<1.7.0)
|
|
17
17
|
Requires-Dist: pandas (>=2.2.3,<3.0.0)
|
|
18
|
-
Requires-Dist: pyModbusTCP (>=0.
|
|
18
|
+
Requires-Dist: pyModbusTCP (>=0.1.9)
|
|
19
19
|
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "pytrms"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.1"
|
|
4
4
|
description = "Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS)."
|
|
5
5
|
authors = ["Moritz Koenemann <moritz.koenemann@ionicon.com>"]
|
|
6
6
|
license = "GPL-2.0"
|
|
@@ -15,7 +15,7 @@ h5py = "^3.12.1"
|
|
|
15
15
|
matplotlib = "^3.9.2"
|
|
16
16
|
requests = "^2.32.3"
|
|
17
17
|
pandas = "^2.2.3"
|
|
18
|
-
pyModbusTCP = "
|
|
18
|
+
pyModbusTCP = ">=0.1.9"
|
|
19
19
|
paho-mqtt = "~1.6.1"
|
|
20
20
|
|
|
21
21
|
[tool.poetry.group.test.dependencies]
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
_version = '0.9.
|
|
1
|
+
_version = '0.9.1'
|
|
2
2
|
|
|
3
3
|
__all__ = ['load', 'connect']
|
|
4
4
|
|
|
@@ -23,7 +23,6 @@ def connect(host=None, method='webapi'):
|
|
|
23
23
|
returns an `Instrument` if connected successfully.
|
|
24
24
|
'''
|
|
25
25
|
from .instrument import Instrument
|
|
26
|
-
from .helpers import PTRConnectionError
|
|
27
26
|
|
|
28
27
|
if method.lower() == 'webapi':
|
|
29
28
|
from .clients.ioniclient import IoniClient
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from . import _logging
|
|
7
|
+
from .ssevent import SSEventListener
|
|
8
|
+
from .._base import IoniClientBase
|
|
9
|
+
|
|
10
|
+
log = _logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# TODO :: sowas waer auch ganz cool: die DBAPI bietes sich geradezu an,
|
|
13
|
+
# da mehr object-oriented zu arbeiten:
|
|
14
|
+
# currentVariable = get_component(currentComponentNameAction, ds)
|
|
15
|
+
# currentVariable.save_value({'value': currentValue})
|
|
16
|
+
|
|
17
|
+
class IoniConnect(IoniClientBase):
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def is_connected(self):
|
|
21
|
+
'''Returns `True` if connection to IoniTOF could be established.'''
|
|
22
|
+
try:
|
|
23
|
+
self.get("/api/status")
|
|
24
|
+
return True
|
|
25
|
+
except:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_running(self):
|
|
30
|
+
'''Returns `True` if IoniTOF is currently acquiring data.'''
|
|
31
|
+
raise NotImplementedError("is_running")
|
|
32
|
+
|
|
33
|
+
def connect(self, timeout_s):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def disconnect(self):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def __init__(self, host='127.0.0.1', port=5066, session=None):
|
|
40
|
+
super().__init__(host, port)
|
|
41
|
+
self.url = f"http://{self.host}:{self.port}"
|
|
42
|
+
if session is None:
|
|
43
|
+
session = requests.sessions.Session()
|
|
44
|
+
self.session = session
|
|
45
|
+
# ??
|
|
46
|
+
self.current_avg_endpoint = None
|
|
47
|
+
self.comp_dict = dict()
|
|
48
|
+
|
|
49
|
+
def get(self, endpoint, **kwargs):
|
|
50
|
+
return self._get_object(endpoint, **kwargs).json()
|
|
51
|
+
|
|
52
|
+
def post(self, endpoint, data, **kwargs):
|
|
53
|
+
return self._create_object(endpoint, data, 'post', **kwargs).headers.get('Location')
|
|
54
|
+
|
|
55
|
+
def put(self, endpoint, data, **kwargs):
|
|
56
|
+
return self._create_object(endpoint, data, 'put', **kwargs).headers.get('Location')
|
|
57
|
+
|
|
58
|
+
def upload(self, endpoint, filename):
|
|
59
|
+
if not endpoint.startswith('/'):
|
|
60
|
+
endpoint = '/' + endpoint
|
|
61
|
+
with open(filename) as f:
|
|
62
|
+
# Note (important!): this is a "form-data" entry, where the server
|
|
63
|
+
# expects the "name" to be 'file' and rejects it otherwise:
|
|
64
|
+
name = 'file'
|
|
65
|
+
r = self.session.post(self.url + endpoint, files=[(name, (filename, f, ''))])
|
|
66
|
+
r.raise_for_status()
|
|
67
|
+
|
|
68
|
+
return r
|
|
69
|
+
|
|
70
|
+
def _get_object(self, endpoint, **kwargs):
|
|
71
|
+
if not endpoint.startswith('/'):
|
|
72
|
+
endpoint = '/' + endpoint
|
|
73
|
+
if 'headers' not in kwargs:
|
|
74
|
+
kwargs['headers'] = {'content-type': 'application/hal+json'}
|
|
75
|
+
elif 'content-type' not in (k.lower() for k in kwargs['headers']):
|
|
76
|
+
kwargs['headers'].update({'content-type': 'application/hal+json'})
|
|
77
|
+
r = self.session.request('get', self.url + endpoint, **kwargs)
|
|
78
|
+
r.raise_for_status()
|
|
79
|
+
|
|
80
|
+
return r
|
|
81
|
+
|
|
82
|
+
def _create_object(self, endpoint, data, method='post', **kwargs):
|
|
83
|
+
if not endpoint.startswith('/'):
|
|
84
|
+
endpoint = '/' + endpoint
|
|
85
|
+
if not isinstance(data, str):
|
|
86
|
+
data = json.dumps(data, ensure_ascii=False) # default is `True`, escapes Umlaute!
|
|
87
|
+
if 'headers' not in kwargs:
|
|
88
|
+
kwargs['headers'] = {'content-type': 'application/hal+json'}
|
|
89
|
+
elif 'content-type' not in (k.lower() for k in kwargs['headers']):
|
|
90
|
+
kwargs['headers'].update({'content-type': 'application/hal+json'})
|
|
91
|
+
r = self.session.request(method, self.url + endpoint, data=data, **kwargs)
|
|
92
|
+
if not r.ok:
|
|
93
|
+
log.error(f"POST {endpoint}\n{data}\n\nreturned [{r.status_code}]: {r.content}")
|
|
94
|
+
r.raise_for_status()
|
|
95
|
+
|
|
96
|
+
return r
|
|
97
|
+
|
|
98
|
+
def sync(self, peaktable):
|
|
99
|
+
"""Compare and upload any differences in `peaktable` to the database."""
|
|
100
|
+
from pytrms.peaktable import Peak, PeakTable
|
|
101
|
+
from operator import attrgetter
|
|
102
|
+
|
|
103
|
+
# Note: a `Peak` is a hashable object that serves as a key that
|
|
104
|
+
# distinguishes between peaks as defined by PyTRMS:
|
|
105
|
+
make_key = lambda peak: Peak(center=peak['center'], label=peak['name'], shift=peak['shift'])
|
|
106
|
+
|
|
107
|
+
if isinstance(peaktable, str):
|
|
108
|
+
log.info(f"loading peaktable '{peaktable}'...")
|
|
109
|
+
peaktable = PeakTable.from_file(peaktable)
|
|
110
|
+
|
|
111
|
+
# get the PyTRMS- and IoniConnect-peaks on the same page:
|
|
112
|
+
conv = {
|
|
113
|
+
'name': attrgetter('label'),
|
|
114
|
+
'center': attrgetter('center'),
|
|
115
|
+
'kRate': attrgetter('k_rate'),
|
|
116
|
+
'low': lambda p: p.borders[0],
|
|
117
|
+
'high': lambda p: p.borders[1],
|
|
118
|
+
'shift': attrgetter('shift'),
|
|
119
|
+
'multiplier': attrgetter('multiplier'),
|
|
120
|
+
}
|
|
121
|
+
# normalize the input argument and create a hashable set:
|
|
122
|
+
updates = dict()
|
|
123
|
+
for peak in peaktable:
|
|
124
|
+
payload = {k: conv[k](peak) for k in conv}
|
|
125
|
+
updates[make_key(payload)] = {'payload': payload}
|
|
126
|
+
|
|
127
|
+
log.info(f"fetching current peaktable from the server...")
|
|
128
|
+
# create a comparable collection of peaks already on the database by
|
|
129
|
+
# reducing the keys in the response to what we actually want to update:
|
|
130
|
+
db_peaks = {make_key(p): {
|
|
131
|
+
'payload': {k: p[k] for k in conv.keys()},
|
|
132
|
+
'self': p['_links']['self'],
|
|
133
|
+
'parent': p['_links'].get('parent'),
|
|
134
|
+
} for p in self.get('/api/peaks')['_embedded']['peaks']}
|
|
135
|
+
|
|
136
|
+
to_update = updates.keys() & db_peaks.keys()
|
|
137
|
+
to_upload = updates.keys() - db_peaks.keys()
|
|
138
|
+
updated = 0
|
|
139
|
+
for key in sorted(to_update):
|
|
140
|
+
# check if an existing peak needs an update
|
|
141
|
+
if db_peaks[key]['payload'] == updates[key]['payload']:
|
|
142
|
+
# nothing to do..
|
|
143
|
+
log.debug(f"up-to-date: {key}")
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
self.put(db_peaks[key]['self']['href'], updates[key]['payload'])
|
|
147
|
+
log.info(f"updated: {key}")
|
|
148
|
+
updated += 1
|
|
149
|
+
|
|
150
|
+
if len(to_upload):
|
|
151
|
+
# Note: POSTing the embedded-collection is *miles faster*
|
|
152
|
+
# than doing separate requests for each peak!
|
|
153
|
+
payload = {'_embedded': {'peaks': [updates[key]['payload'] for key in sorted(to_upload)]}}
|
|
154
|
+
self.post('/api/peaks', payload)
|
|
155
|
+
for key in sorted(to_upload): log.info(f"added new: {key}")
|
|
156
|
+
|
|
157
|
+
# Note: this disregards the peak-parent-relationship, but in
|
|
158
|
+
# order to implement this correctly, one would need to check
|
|
159
|
+
# if the parent-peak with a specific 'parentID' is already
|
|
160
|
+
# uploaded and search it.. there's an endpoint
|
|
161
|
+
# 'LINK /api/peaks/{parentID} Location: /api/peaks/{childID}'
|
|
162
|
+
# to link a child to its parent, but it remains complicated.
|
|
163
|
+
# TODO :: maybe later implement parent-peaks!?
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
'added': len(to_upload),
|
|
167
|
+
'updated': updated,
|
|
168
|
+
'up-to-date': len(to_update) - updated,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def iter_events(self, event_re=r".*"):
|
|
172
|
+
"""Follow the server-sent-events (SSE) on the DB-API.
|
|
173
|
+
|
|
174
|
+
`event_re` a regular expression to filter events (default: matches everything)
|
|
175
|
+
"""
|
|
176
|
+
yield from SSEventListener(event_re, host_url=self.url, endpoint="/api/events",
|
|
177
|
+
session=self.session)
|
|
178
|
+
|
|
@@ -18,17 +18,17 @@ class IoniClient:
|
|
|
18
18
|
Access the Ionicon WebAPI.
|
|
19
19
|
|
|
20
20
|
Usage:
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
> client = IoniClient()
|
|
22
|
+
> client.get('TPS_Pull_H')
|
|
23
23
|
{'TPS_Pull_H': 123.45, ... }
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
> client.set('TPS_Pull_H', 42)
|
|
26
26
|
{'TPS_Pull_H': 42.0, ... }
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
> client.start_measurement()
|
|
29
29
|
ACK
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
> client.host, client.port
|
|
32
32
|
('localhost', 8002)
|
|
33
33
|
|
|
34
34
|
'''
|
|
@@ -85,19 +85,3 @@ class IoniClient:
|
|
|
85
85
|
|
|
86
86
|
def stop_measurement(self):
|
|
87
87
|
return self.set('ACQ_SRV_Stop_Meas', 1)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if __name__ == '__main__':
|
|
91
|
-
import sys
|
|
92
|
-
client = IoniClient()
|
|
93
|
-
|
|
94
|
-
if len(sys.argv) == 2:
|
|
95
|
-
print(client.get(sys.argv[1]))
|
|
96
|
-
elif len(sys.argv) == 3:
|
|
97
|
-
print(client.set(sys.argv[1], sys.argv[2]))
|
|
98
|
-
else:
|
|
99
|
-
print(f"""\
|
|
100
|
-
usage:
|
|
101
|
-
python {sys.argv[0]} <varname> [<value>]
|
|
102
|
-
""")
|
|
103
|
-
|
|
@@ -9,7 +9,7 @@ from collections import namedtuple
|
|
|
9
9
|
from functools import lru_cache
|
|
10
10
|
from itertools import tee
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
import pyModbusTCP.client
|
|
13
13
|
|
|
14
14
|
from . import _par_id_file
|
|
15
15
|
from .._base.ioniclient import IoniClientBase
|
|
@@ -19,9 +19,28 @@ log = logging.getLogger(__name__)
|
|
|
19
19
|
__all__ = ['IoniconModbus']
|
|
20
20
|
|
|
21
21
|
|
|
22
|
+
def _patch_is_open():
|
|
23
|
+
# Note: the .is_open and .timeout attributes were changed
|
|
24
|
+
# from a function to a property!
|
|
25
|
+
#
|
|
26
|
+
# 0.2.0 2022-06-05
|
|
27
|
+
#
|
|
28
|
+
# - ModbusClient: parameters are now properties instead of methods (more intuitive).
|
|
29
|
+
#
|
|
30
|
+
# from the [changelog](https://github.com/sourceperl/pyModbusTCP/blob/master/CHANGES):
|
|
31
|
+
major, minor, patch = pyModbusTCP.__version__.split('.')
|
|
32
|
+
if int(minor) < 2:
|
|
33
|
+
return lambda mc: mc.is_open()
|
|
34
|
+
else:
|
|
35
|
+
return lambda mc: mc.is_open
|
|
36
|
+
|
|
37
|
+
_is_open = _patch_is_open()
|
|
38
|
+
|
|
22
39
|
with open(_par_id_file) as f:
|
|
23
40
|
it = iter(f)
|
|
24
|
-
assert next(it).startswith('ID\tName'), "Modbus parameter file is corrupt: "
|
|
41
|
+
assert next(it).startswith('ID\tName'), ("Modbus parameter file is corrupt: "
|
|
42
|
+
+ f.name
|
|
43
|
+
+ "\n\ntry re-installing the PyTRMS python package to fix it!")
|
|
25
44
|
_id_to_descr = {int(id_): name for id_, name, *_ in (line.strip().split('\t') for line in it)}
|
|
26
45
|
|
|
27
46
|
# look-up-table for c_structs (see docstring of struct-module for more info).
|
|
@@ -59,10 +78,10 @@ def _unpack(registers, format='>f'):
|
|
|
59
78
|
representation, respectively.
|
|
60
79
|
|
|
61
80
|
>>> _unpack([17448, 0], 'float')
|
|
62
|
-
672.
|
|
81
|
+
672.0
|
|
63
82
|
|
|
64
83
|
>>> _unpack([17446, 32768], 'float')
|
|
65
|
-
666.
|
|
84
|
+
666.0
|
|
66
85
|
|
|
67
86
|
>>> _unpack([16875, 61191, 54426, 37896], 'double')
|
|
68
87
|
3749199524.83057
|
|
@@ -132,7 +151,7 @@ class IoniconModbus(IoniClientBase):
|
|
|
132
151
|
|
|
133
152
|
@property
|
|
134
153
|
def is_connected(self):
|
|
135
|
-
if not self.mc
|
|
154
|
+
if not _is_open(self.mc):
|
|
136
155
|
return False
|
|
137
156
|
|
|
138
157
|
# wait for the IoniTOF alive-counter to change (1 second max)...
|
|
@@ -175,7 +194,18 @@ class IoniconModbus(IoniClientBase):
|
|
|
175
194
|
|
|
176
195
|
def __init__(self, host='localhost', port=502):
|
|
177
196
|
super().__init__(host, port)
|
|
178
|
-
|
|
197
|
+
# Note: we patch the behaviour such, that it behaves like pre-0.2
|
|
198
|
+
# (from the time of development of this module), BUT we skip the
|
|
199
|
+
# auto_close-feature for the sake of speed:
|
|
200
|
+
#
|
|
201
|
+
# 0.2.0 2022-06-05
|
|
202
|
+
#
|
|
203
|
+
# - ModbusClient: now TCP auto open mode is active by default (auto_open=True, auto_close=False).
|
|
204
|
+
#
|
|
205
|
+
# from the [changelog](https://github.com/sourceperl/pyModbusTCP/blob/master/CHANGES)
|
|
206
|
+
self.mc = pyModbusTCP.client.ModbusClient(host=self.host, port=self.port,
|
|
207
|
+
auto_open = False, auto_close = False
|
|
208
|
+
)
|
|
179
209
|
# try connect immediately:
|
|
180
210
|
try:
|
|
181
211
|
self.connect()
|
|
@@ -185,8 +215,11 @@ class IoniconModbus(IoniClientBase):
|
|
|
185
215
|
|
|
186
216
|
def connect(self, timeout_s=10):
|
|
187
217
|
log.info(f"[{self}] connecting to Modbus server...")
|
|
188
|
-
|
|
189
|
-
self.mc.
|
|
218
|
+
# Note: .timeout-attribute changed to a property with 0.2.0 (see comments above)
|
|
219
|
+
if callable(self.mc.timeout):
|
|
220
|
+
self.mc.timeout(timeout_s)
|
|
221
|
+
else:
|
|
222
|
+
self.mc.timeout = timeout_s
|
|
190
223
|
if not self.mc.open():
|
|
191
224
|
raise TimeoutError(f"[{self}] no connection to modbus socket")
|
|
192
225
|
|
|
@@ -201,7 +234,7 @@ class IoniconModbus(IoniClientBase):
|
|
|
201
234
|
raise TimeoutError(f"[{self}] no connection to IoniTOF");
|
|
202
235
|
|
|
203
236
|
def disconnect(self):
|
|
204
|
-
if self.mc
|
|
237
|
+
if _is_open(self.mc):
|
|
205
238
|
self.mc.close()
|
|
206
239
|
|
|
207
240
|
@property
|
|
@@ -452,9 +485,9 @@ class IoniconModbus(IoniClientBase):
|
|
|
452
485
|
_read = self.mc.read_holding_registers if is_holding_register else self.mc.read_input_registers
|
|
453
486
|
|
|
454
487
|
register = _read(addr, n_bytes)
|
|
455
|
-
if register is None and self.mc
|
|
488
|
+
if register is None and _is_open(self.mc):
|
|
456
489
|
raise IOError(f"unable to read ({n_bytes}) registers at [{addr}] from connection")
|
|
457
|
-
elif register is None and not self.mc
|
|
490
|
+
elif register is None and not _is_open(self.mc):
|
|
458
491
|
raise IOError("trying to read from closed Modbus-connection")
|
|
459
492
|
|
|
460
493
|
return _unpack(register, c_format)
|
|
@@ -325,9 +325,14 @@ def follow_state(client, self, msg):
|
|
|
325
325
|
log.debug(f"[{self}] new server-state: " + str(state))
|
|
326
326
|
# replace the current state with the new element:
|
|
327
327
|
self._server_state.append(state)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
meas_running = (state == "ACQ_Aquire") # yes, there's a typo, plz keep it :)
|
|
329
|
+
just_started = (meas_running and not msg.retain)
|
|
330
|
+
if meas_running:
|
|
331
|
+
# signal the relevant thread(s) that we need an update:
|
|
332
|
+
self._calcconzinfo.append(_NOT_INIT)
|
|
333
|
+
if just_started:
|
|
334
|
+
# invalidate the source-file until we get a new one:
|
|
335
|
+
self._sf_filename.append(_NOT_INIT)
|
|
331
336
|
|
|
332
337
|
follow_state.topics = ["DataCollection/Act/ACQ_SRV_CurrentState"]
|
|
333
338
|
|
|
@@ -404,8 +409,8 @@ _NOT_INIT = object()
|
|
|
404
409
|
class MqttClient(MqttClientBase):
|
|
405
410
|
"""a simplified client for the Ionicon MQTT API.
|
|
406
411
|
|
|
407
|
-
|
|
408
|
-
|
|
412
|
+
> mq = MqttClient()
|
|
413
|
+
> mq.write('TCP_MCP_B', 3400)
|
|
409
414
|
ValueError()
|
|
410
415
|
|
|
411
416
|
"""
|
|
@@ -463,11 +468,27 @@ class MqttClient(MqttClientBase):
|
|
|
463
468
|
|
|
464
469
|
@property
|
|
465
470
|
def current_sourcefile(self):
|
|
466
|
-
'''Returns the path to the hdf5-file that is currently
|
|
471
|
+
'''Returns the path to the hdf5-file that is currently being written.
|
|
467
472
|
|
|
468
|
-
|
|
473
|
+
Returns an empty string if no measurement is running.
|
|
469
474
|
'''
|
|
470
|
-
|
|
475
|
+
if not self.is_running:
|
|
476
|
+
return ""
|
|
477
|
+
|
|
478
|
+
if self._sf_filename[0] is not _NOT_INIT:
|
|
479
|
+
return self._sf_filename[0]
|
|
480
|
+
|
|
481
|
+
# Note: '_NOT_INIT' is set by us on start of acquisition, so we'd expect
|
|
482
|
+
# to receive the source-file-topic after a (generous) timeout:
|
|
483
|
+
timeout_s = 15
|
|
484
|
+
started_at = time.monotonic()
|
|
485
|
+
while time.monotonic() < started_at + timeout_s:
|
|
486
|
+
if self._sf_filename[0] is not _NOT_INIT:
|
|
487
|
+
return self._sf_filename[0]
|
|
488
|
+
|
|
489
|
+
time.sleep(10e-3)
|
|
490
|
+
else:
|
|
491
|
+
raise TimeoutError(f"[{self}] unable to retrieve source-file after ({timeout_s = })");
|
|
471
492
|
|
|
472
493
|
@property
|
|
473
494
|
def current_cycle(self):
|
|
@@ -508,12 +529,10 @@ class MqttClient(MqttClientBase):
|
|
|
508
529
|
if _lut is self.set_values and is_read_only:
|
|
509
530
|
raise ValueError(f"'{parID}' is read-only, did you mean `kind='act'`?")
|
|
510
531
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return _lut[parID]
|
|
516
|
-
except KeyError as exc:
|
|
532
|
+
if not parID in _lut:
|
|
533
|
+
# Note: The values should need NO! time to be populated from the MQTT topics,
|
|
534
|
+
# because all topics are published as *retained* by the PTR-server.
|
|
535
|
+
# However, a short timeout is respected before raising a `KeyError`:
|
|
517
536
|
time.sleep(200e-3)
|
|
518
537
|
rv = _lut.get(parID)
|
|
519
538
|
if rv is not None:
|
|
@@ -525,6 +544,7 @@ class MqttClient(MqttClientBase):
|
|
|
525
544
|
"set" if parID in self.set_values else
|
|
526
545
|
"")
|
|
527
546
|
raise KeyError(str(parID) + (' (did you mean `kind="%s"`?)' % error_hint) if error_hint else "")
|
|
547
|
+
return _lut[parID]
|
|
528
548
|
|
|
529
549
|
def get_table(self, table_name):
|
|
530
550
|
timeout_s = 10
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import namedtuple
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from . import _logging
|
|
8
|
+
|
|
9
|
+
log = _logging.getLogger()
|
|
10
|
+
|
|
11
|
+
_event_rv = namedtuple('ssevent', ['event', 'data'])
|
|
12
|
+
|
|
13
|
+
class SSEventListener(Iterable):
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def _line_stream(response):
|
|
17
|
+
# Note: using .iter_content() seems to yield results faster than .iter_lines()
|
|
18
|
+
line = ''
|
|
19
|
+
for bite in response.iter_content(chunk_size=1, decode_unicode=True):
|
|
20
|
+
line += bite
|
|
21
|
+
if bite == '\n':
|
|
22
|
+
yield line
|
|
23
|
+
line = ''
|
|
24
|
+
|
|
25
|
+
def __init__(self, event_re=None, host_url='http://127.0.0.1:5066',
|
|
26
|
+
endpoint='/api/events', session=None):
|
|
27
|
+
self.uri = host_url + endpoint
|
|
28
|
+
if session is not None:
|
|
29
|
+
self._get = session.get
|
|
30
|
+
else:
|
|
31
|
+
self._get = requests.get
|
|
32
|
+
self._connect_response = None
|
|
33
|
+
self.subscriptions = set()
|
|
34
|
+
if event_re is not None:
|
|
35
|
+
self.subscribe(event_re)
|
|
36
|
+
|
|
37
|
+
def subscribe(self, event_re):
|
|
38
|
+
"""Listen for events matching the given string or regular expression."""
|
|
39
|
+
self.subscriptions.add(re.compile(event_re))
|
|
40
|
+
if self._connect_response is None:
|
|
41
|
+
r = self._get(self.uri, headers={'accept': 'text/event-stream'}, stream=True)
|
|
42
|
+
if not r.ok:
|
|
43
|
+
log.error(f"no connection to {self.uri} (got [{r.status_code}])")
|
|
44
|
+
r.raise_for_status()
|
|
45
|
+
|
|
46
|
+
self._connect_response = r
|
|
47
|
+
|
|
48
|
+
def unsubscribe(self, event_re):
|
|
49
|
+
"""Stop listening for certain events."""
|
|
50
|
+
self.subscriptions.remove(re.compile(event_re))
|
|
51
|
+
if not len(self.subscriptions):
|
|
52
|
+
log.debug(f"closing connection to {self.uri}")
|
|
53
|
+
self._connect_response.close()
|
|
54
|
+
self._connect_response = None
|
|
55
|
+
|
|
56
|
+
def __iter__(self):
|
|
57
|
+
if self._connect_response is None:
|
|
58
|
+
raise Exception("call .subscribe() first to listen for events")
|
|
59
|
+
|
|
60
|
+
event = msg = ''
|
|
61
|
+
for line in self._line_stream(self._connect_response): # blocks...
|
|
62
|
+
if not line.strip():
|
|
63
|
+
if event and any(re.match(sub, event) for sub in self.subscriptions):
|
|
64
|
+
yield _event_rv(event, msg)
|
|
65
|
+
event = msg = ''
|
|
66
|
+
else:
|
|
67
|
+
log.log(_logging.TRACE, "sse: still alive...")
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
key, val = line.split(':', maxsplit=1)
|
|
71
|
+
if key == 'event':
|
|
72
|
+
event = val.strip()
|
|
73
|
+
elif key == 'data':
|
|
74
|
+
msg += val.strip()
|
|
75
|
+
else:
|
|
76
|
+
log.warning(f"unknown SSE-key <{key}> in stream")
|
|
77
|
+
|