openrvdas.logger.readers.cached_data_reader

No module-level documentation available.
  1#!/usr/bin/env python3
  2
  3import asyncio
  4import json
  5import logging
  6import queue
  7import ssl
  8import threading
  9import time
 10
 11try:
 12    import websockets
 13    WEBSOCKETS_ENABLED = True
 14except ModuleNotFoundError:
 15    WEBSOCKETS_ENABLED = False
 16
 17from logger.readers.reader import Reader  # noqa: E402
 18from logger.utils.das_record import to_das_record_list  # noqa: E402
 19
 20DEFAULT_SERVER_WEBSOCKET = 'localhost:8766'
 21
 22
 23################################################################################
 24class CachedDataReader(Reader):
 25    """Subscribe to and read field values from a CachedDataServer via
 26    websocket connection.
 27    """
 28
 29    def __init__(self, subscription, data_server=DEFAULT_SERVER_WEBSOCKET,
 30                 bundle_seconds=0, return_das_record=False, data_id=None,
 31                 use_wss=False, check_cert=False, **kwargs):
 32        """
 33        ```
 34        subscription - a dictionary corresponding to the full
 35            fields/seconds, etc that the reader wishes, following the
 36            conventions described in logger/utils/cached_data_server.py
 37            e.g:
 38
 39            subscription = {'fields':{'S330CourseTrue':{seconds:0},
 40                                      'S330HeadingTrue':{seconds:0}}}
 41
 42            If the value of 'fields' is a list instead of a dict, it will be
 43            interpreted as a list of field names to be subscribed to with a
 44            value of seconds = 0. e.g.:
 45
 46            subscription = {'fields':['S330CourseTrue', 'S330HeadingTrue']}
 47
 48        data_server - the host and port at which to try to connect to a
 49            CachedDataServer
 50
 51        bundle_seconds - If specified, aggregate this many seconds worth of records
 52                      and return as a list of records. Note that as implemented, it
 53                      bundles by system clock time, and not by DASRecord timestamp.
 54
 55        return_das_record - If True, return results as DASRecords.
 56
 57        data_id - If return_das_record, use this as the records' data_id
 58
 59        use_wss -     If True, use secure websockets
 60
 61        check_cert  - If True and use_wss is True, check the server's TLS certificate
 62                      for validity; if a str, use as local filepath location of .pem
 63                      file to check against.
 64        ```
 65        When invoked in a config file, this would be:
 66        ```
 67          readers:
 68            class: CachedDataServer
 69            kwargs:
 70              data_server: localhost:8766
 71              subscription:
 72                fields:
 73                  S330CourseTrue:
 74                    seconds: 0
 75                  S330HeadingTrue:
 76                    seconds: 0
 77        ```
 78        """
 79        super().__init__(**kwargs)
 80
 81        if not WEBSOCKETS_ENABLED:
 82            raise ModuleNotFoundError('CachedDataReader(): websockets module is not '
 83                                      'installed. Please try "pip3 install '
 84                                      'websockets" prior to use.')
 85        if not (isinstance(bundle_seconds, int) or isinstance(bundle_seconds, float)) \
 86                or bundle_seconds < 0:
 87            raise ValueError('CachedDataReader parameter "bundle_seconds" must be a number '
 88                             f'greater than or equal to zero. Found "{bundle_seconds}"')
 89
 90        # To simplify templating, subscription may be a list of fields instead of a dict.
 91        # If so, convert it to a dict here.from
 92        subscription_fields = subscription.get('fields')
 93        if not subscription_fields:
 94            raise ValueError('CachedDataReader subscription - no "fields" found!')
 95        elif isinstance(subscription_fields, list):
 96            new_fields = {field: {'seconds': 0} for field in subscription_fields}
 97            subscription['fields'] = new_fields
 98
 99        self.subscription = subscription
100        subscription['type'] = 'subscribe'
101        self.data_server = data_server
102        self.bundle_seconds = bundle_seconds
103        self.return_das_record = return_das_record
104        self.data_id = data_id
105        self.use_wss = use_wss
106        self.check_cert = check_cert
107
108        # We won't initialize our websocket until the first read()
109        # call. At that point we'll launch an async process in a separate
110        # thread that will wait for data from the websocket and put it in
111        # a queue that read() will pop from.
112        self.websocket_thread = None
113        self.queue = queue.Queue()
114        self.quit_flag = False
115
116    ############################
117    def _parse_response(self, response):
118        """Parse a CachedDataServer response and enqueue the resulting data."""
119        if not response.get('type') == 'data':
120            logging.info('Non-"data" response received from data '
121                         'server: %s', response)
122            return
123        if not response.get('status') == 200:
124            logging.warning('Non-"200" status received from data '
125                            'server: %s', response)
126            return
127        data = response.get('data')
128        if not data:
129            logging.debug('No data found in data server response?: %s', response)
130            return
131
132        # If we've gotten a list, assume/hope it's a list of
133        # DASRecord-like dicts; that means it's already collated for us by
134        # timestamp.
135        if type(data) is list:
136            for entry in data:
137                self.queue.put(entry)
138            return
139
140        # Otherwise we expect it to be a field dict, and need to collate
141        # by timestamp manually.
142        if not type(data) is dict:
143            logging.warning('Data from data server not a dict?!?: %s', response)
144            return
145
146        # Collate the fields/values by timestamp
147        timestamp_dict = {}
148        for field, values in data.items():
149            for timestamp, value in values:  # should be list of [ts, value] pairs
150                if timestamp not in timestamp_dict:
151                    timestamp_dict[timestamp] = {}
152                timestamp_dict[timestamp][field] = value
153
154        # Enqueue entries by timestamp
155        for timestamp in sorted(timestamp_dict.keys()):
156            entry = {'timestamp': timestamp, 'fields': timestamp_dict[timestamp]}
157            logging.debug('Enqueuing from CDS: %s', entry)
158            self.queue.put(entry)
159
160    ############################
161    def _start_websocket(self):
162        """We'll run this in a separate thread as soon as we get our first
163        call to read()."""
164
165        ############################
166        async def _websocket_loop(self):
167            """Asynchronous inner function that will read from websocket and put
168            the result in our queue.
169            """
170            # Iterate if we lose the websocket for some reason other than a 'quit'
171            while not self.quit_flag:
172                try:
173                    if self.use_wss:
174                        # If check_cert is a str, take it as the location of the
175                        # .pem file we'll check for validity. Otherwise, if not
176                        # False, take as a bool to verify by own means.
177                        ws_data_server = 'wss://' + self.data_server
178                        ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS)
179                        if self.check_cert:
180                            if isinstance(self.check_cert, str):
181                                ssl_context.load_verify_locations(self.check_cert)
182                            else:
183                                ssl_context.verify_mode = ssl.CERT_REQUIRED
184                        else:
185                            ssl_context.verify_mode = ssl.CERT_NONE
186
187                    else:  # not using wss
188                        ws_data_server = 'ws://' + self.data_server
189                        ssl_context = None
190
191                    logging.info(f'CachedDataReader connecting to {ws_data_server}')
192                    async with websockets.connect(ws_data_server, ssl=ssl_context) as ws:
193                        logging.info(f'Connected to data server {ws_data_server}')
194                        # Send our subscription request
195                        await ws.send(json.dumps(self.subscription))
196                        result = await ws.recv()
197                        response = json.loads(result)
198
199                        while not self.quit_flag:
200                            await ws.send(json.dumps({'type': 'ready'}))
201                            result = await ws.recv()
202                            response = json.loads(result)
203                            logging.debug('Got CachedDataServer response: %s', response)
204                            self._parse_response(response)
205
206                except BrokenPipeError:
207                    pass
208                except AttributeError as e:
209                    logging.info('CachedDataReader websocket loop error: %s', e)
210                except websockets.exceptions.ConnectionClosed:
211                    logging.warning('CachedDataReader lost websocket connection to '
212                                    'data server; trying to reconnect.')
213                    await asyncio.sleep(0.2)
214
215                except websockets.exceptions.InvalidStatusCode:
216                    logging.warning('CachedDataWriter InvalidStatusCode connecting to '
217                                    'data server; trying to reconnect.')
218                    await asyncio.sleep(0.2)
219
220                except OSError as e:
221                    logging.info('Unable to connect to data server. '
222                                 'Sleeping to try again...')
223                    logging.info('Connection error: %s', str(e))
224                    await asyncio.sleep(5)
225
226        # In the outer function, get a new event loop and fire up the
227        # inner, async routine.
228        self.websocket_initialized = True
229
230        # Could we also use asyncio.ensure_future(_websocket_loop(self)) ?
231
232        websocket_event_loop = asyncio.new_event_loop()
233        websocket_event_loop.run_until_complete(_websocket_loop(self))
234        websocket_event_loop.close()
235
236    ############################
237    def quit(self, seconds=0):
238        """Sleep N seconds, then signal quit."""
239        time.sleep(seconds)
240        self.quit_flag = True
241
242    ############################
243    def read(self):
244        """Read/wait for data from the websocket."""
245
246        # If we've not yet fired up the websocket thread, do that now.
247        if not self.websocket_thread:
248            self.websocket_thread = threading.Thread(
249                name='websocket_thread',
250                target=self._start_websocket,
251                daemon=True)
252            self.websocket_thread.start()
253
254        start_time = time.time()
255        result_list = []
256
257        # Use a timeout in our queue get() so we can periodically check if
258        # we've gotten a 'quit'
259        while not self.quit_flag:
260            try:
261                result = self.queue.get(timeout=1)
262                logging.debug('Got result from queue: %s', result)
263
264                # If we're not bundling results, just return the result
265                if not self.bundle_seconds:
266                    if self.return_das_record:
267                        result = to_das_record_list(result, data_id=self.data_id)
268                    return result
269                else:
270                    result_list.append(result)
271            except queue.Empty:
272                logging.debug('get() timed out - trying again')
273                pass
274
275            # If we've been bundling long enough, return the list of records,
276            # or None if the list is empty.
277            now = time.time()
278            if now > start_time + self.bundle_seconds:
279                if self.return_das_record:
280                    result_list = to_das_record_list(result_list, data_id=self.data_id)
281                return result_list
282
283        # If we've fallen out because of a quit...
284        return None
DEFAULT_SERVER_WEBSOCKET = 'localhost:8766'
class CachedDataReader(logger.readers.reader.Reader):
 25class CachedDataReader(Reader):
 26    """Subscribe to and read field values from a CachedDataServer via
 27    websocket connection.
 28    """
 29
 30    def __init__(self, subscription, data_server=DEFAULT_SERVER_WEBSOCKET,
 31                 bundle_seconds=0, return_das_record=False, data_id=None,
 32                 use_wss=False, check_cert=False, **kwargs):
 33        """
 34        ```
 35        subscription - a dictionary corresponding to the full
 36            fields/seconds, etc that the reader wishes, following the
 37            conventions described in logger/utils/cached_data_server.py
 38            e.g:
 39
 40            subscription = {'fields':{'S330CourseTrue':{seconds:0},
 41                                      'S330HeadingTrue':{seconds:0}}}
 42
 43            If the value of 'fields' is a list instead of a dict, it will be
 44            interpreted as a list of field names to be subscribed to with a
 45            value of seconds = 0. e.g.:
 46
 47            subscription = {'fields':['S330CourseTrue', 'S330HeadingTrue']}
 48
 49        data_server - the host and port at which to try to connect to a
 50            CachedDataServer
 51
 52        bundle_seconds - If specified, aggregate this many seconds worth of records
 53                      and return as a list of records. Note that as implemented, it
 54                      bundles by system clock time, and not by DASRecord timestamp.
 55
 56        return_das_record - If True, return results as DASRecords.
 57
 58        data_id - If return_das_record, use this as the records' data_id
 59
 60        use_wss -     If True, use secure websockets
 61
 62        check_cert  - If True and use_wss is True, check the server's TLS certificate
 63                      for validity; if a str, use as local filepath location of .pem
 64                      file to check against.
 65        ```
 66        When invoked in a config file, this would be:
 67        ```
 68          readers:
 69            class: CachedDataServer
 70            kwargs:
 71              data_server: localhost:8766
 72              subscription:
 73                fields:
 74                  S330CourseTrue:
 75                    seconds: 0
 76                  S330HeadingTrue:
 77                    seconds: 0
 78        ```
 79        """
 80        super().__init__(**kwargs)
 81
 82        if not WEBSOCKETS_ENABLED:
 83            raise ModuleNotFoundError('CachedDataReader(): websockets module is not '
 84                                      'installed. Please try "pip3 install '
 85                                      'websockets" prior to use.')
 86        if not (isinstance(bundle_seconds, int) or isinstance(bundle_seconds, float)) \
 87                or bundle_seconds < 0:
 88            raise ValueError('CachedDataReader parameter "bundle_seconds" must be a number '
 89                             f'greater than or equal to zero. Found "{bundle_seconds}"')
 90
 91        # To simplify templating, subscription may be a list of fields instead of a dict.
 92        # If so, convert it to a dict here.from
 93        subscription_fields = subscription.get('fields')
 94        if not subscription_fields:
 95            raise ValueError('CachedDataReader subscription - no "fields" found!')
 96        elif isinstance(subscription_fields, list):
 97            new_fields = {field: {'seconds': 0} for field in subscription_fields}
 98            subscription['fields'] = new_fields
 99
100        self.subscription = subscription
101        subscription['type'] = 'subscribe'
102        self.data_server = data_server
103        self.bundle_seconds = bundle_seconds
104        self.return_das_record = return_das_record
105        self.data_id = data_id
106        self.use_wss = use_wss
107        self.check_cert = check_cert
108
109        # We won't initialize our websocket until the first read()
110        # call. At that point we'll launch an async process in a separate
111        # thread that will wait for data from the websocket and put it in
112        # a queue that read() will pop from.
113        self.websocket_thread = None
114        self.queue = queue.Queue()
115        self.quit_flag = False
116
117    ############################
118    def _parse_response(self, response):
119        """Parse a CachedDataServer response and enqueue the resulting data."""
120        if not response.get('type') == 'data':
121            logging.info('Non-"data" response received from data '
122                         'server: %s', response)
123            return
124        if not response.get('status') == 200:
125            logging.warning('Non-"200" status received from data '
126                            'server: %s', response)
127            return
128        data = response.get('data')
129        if not data:
130            logging.debug('No data found in data server response?: %s', response)
131            return
132
133        # If we've gotten a list, assume/hope it's a list of
134        # DASRecord-like dicts; that means it's already collated for us by
135        # timestamp.
136        if type(data) is list:
137            for entry in data:
138                self.queue.put(entry)
139            return
140
141        # Otherwise we expect it to be a field dict, and need to collate
142        # by timestamp manually.
143        if not type(data) is dict:
144            logging.warning('Data from data server not a dict?!?: %s', response)
145            return
146
147        # Collate the fields/values by timestamp
148        timestamp_dict = {}
149        for field, values in data.items():
150            for timestamp, value in values:  # should be list of [ts, value] pairs
151                if timestamp not in timestamp_dict:
152                    timestamp_dict[timestamp] = {}
153                timestamp_dict[timestamp][field] = value
154
155        # Enqueue entries by timestamp
156        for timestamp in sorted(timestamp_dict.keys()):
157            entry = {'timestamp': timestamp, 'fields': timestamp_dict[timestamp]}
158            logging.debug('Enqueuing from CDS: %s', entry)
159            self.queue.put(entry)
160
161    ############################
162    def _start_websocket(self):
163        """We'll run this in a separate thread as soon as we get our first
164        call to read()."""
165
166        ############################
167        async def _websocket_loop(self):
168            """Asynchronous inner function that will read from websocket and put
169            the result in our queue.
170            """
171            # Iterate if we lose the websocket for some reason other than a 'quit'
172            while not self.quit_flag:
173                try:
174                    if self.use_wss:
175                        # If check_cert is a str, take it as the location of the
176                        # .pem file we'll check for validity. Otherwise, if not
177                        # False, take as a bool to verify by own means.
178                        ws_data_server = 'wss://' + self.data_server
179                        ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS)
180                        if self.check_cert:
181                            if isinstance(self.check_cert, str):
182                                ssl_context.load_verify_locations(self.check_cert)
183                            else:
184                                ssl_context.verify_mode = ssl.CERT_REQUIRED
185                        else:
186                            ssl_context.verify_mode = ssl.CERT_NONE
187
188                    else:  # not using wss
189                        ws_data_server = 'ws://' + self.data_server
190                        ssl_context = None
191
192                    logging.info(f'CachedDataReader connecting to {ws_data_server}')
193                    async with websockets.connect(ws_data_server, ssl=ssl_context) as ws:
194                        logging.info(f'Connected to data server {ws_data_server}')
195                        # Send our subscription request
196                        await ws.send(json.dumps(self.subscription))
197                        result = await ws.recv()
198                        response = json.loads(result)
199
200                        while not self.quit_flag:
201                            await ws.send(json.dumps({'type': 'ready'}))
202                            result = await ws.recv()
203                            response = json.loads(result)
204                            logging.debug('Got CachedDataServer response: %s', response)
205                            self._parse_response(response)
206
207                except BrokenPipeError:
208                    pass
209                except AttributeError as e:
210                    logging.info('CachedDataReader websocket loop error: %s', e)
211                except websockets.exceptions.ConnectionClosed:
212                    logging.warning('CachedDataReader lost websocket connection to '
213                                    'data server; trying to reconnect.')
214                    await asyncio.sleep(0.2)
215
216                except websockets.exceptions.InvalidStatusCode:
217                    logging.warning('CachedDataWriter InvalidStatusCode connecting to '
218                                    'data server; trying to reconnect.')
219                    await asyncio.sleep(0.2)
220
221                except OSError as e:
222                    logging.info('Unable to connect to data server. '
223                                 'Sleeping to try again...')
224                    logging.info('Connection error: %s', str(e))
225                    await asyncio.sleep(5)
226
227        # In the outer function, get a new event loop and fire up the
228        # inner, async routine.
229        self.websocket_initialized = True
230
231        # Could we also use asyncio.ensure_future(_websocket_loop(self)) ?
232
233        websocket_event_loop = asyncio.new_event_loop()
234        websocket_event_loop.run_until_complete(_websocket_loop(self))
235        websocket_event_loop.close()
236
237    ############################
238    def quit(self, seconds=0):
239        """Sleep N seconds, then signal quit."""
240        time.sleep(seconds)
241        self.quit_flag = True
242
243    ############################
244    def read(self):
245        """Read/wait for data from the websocket."""
246
247        # If we've not yet fired up the websocket thread, do that now.
248        if not self.websocket_thread:
249            self.websocket_thread = threading.Thread(
250                name='websocket_thread',
251                target=self._start_websocket,
252                daemon=True)
253            self.websocket_thread.start()
254
255        start_time = time.time()
256        result_list = []
257
258        # Use a timeout in our queue get() so we can periodically check if
259        # we've gotten a 'quit'
260        while not self.quit_flag:
261            try:
262                result = self.queue.get(timeout=1)
263                logging.debug('Got result from queue: %s', result)
264
265                # If we're not bundling results, just return the result
266                if not self.bundle_seconds:
267                    if self.return_das_record:
268                        result = to_das_record_list(result, data_id=self.data_id)
269                    return result
270                else:
271                    result_list.append(result)
272            except queue.Empty:
273                logging.debug('get() timed out - trying again')
274                pass
275
276            # If we've been bundling long enough, return the list of records,
277            # or None if the list is empty.
278            now = time.time()
279            if now > start_time + self.bundle_seconds:
280                if self.return_das_record:
281                    result_list = to_das_record_list(result_list, data_id=self.data_id)
282                return result_list
283
284        # If we've fallen out because of a quit...
285        return None

Subscribe to and read field values from a CachedDataServer via websocket connection.

CachedDataReader( subscription, data_server='localhost:8766', bundle_seconds=0, return_das_record=False, data_id=None, use_wss=False, check_cert=False, **kwargs)
 30    def __init__(self, subscription, data_server=DEFAULT_SERVER_WEBSOCKET,
 31                 bundle_seconds=0, return_das_record=False, data_id=None,
 32                 use_wss=False, check_cert=False, **kwargs):
 33        """
 34        ```
 35        subscription - a dictionary corresponding to the full
 36            fields/seconds, etc that the reader wishes, following the
 37            conventions described in logger/utils/cached_data_server.py
 38            e.g:
 39
 40            subscription = {'fields':{'S330CourseTrue':{seconds:0},
 41                                      'S330HeadingTrue':{seconds:0}}}
 42
 43            If the value of 'fields' is a list instead of a dict, it will be
 44            interpreted as a list of field names to be subscribed to with a
 45            value of seconds = 0. e.g.:
 46
 47            subscription = {'fields':['S330CourseTrue', 'S330HeadingTrue']}
 48
 49        data_server - the host and port at which to try to connect to a
 50            CachedDataServer
 51
 52        bundle_seconds - If specified, aggregate this many seconds worth of records
 53                      and return as a list of records. Note that as implemented, it
 54                      bundles by system clock time, and not by DASRecord timestamp.
 55
 56        return_das_record - If True, return results as DASRecords.
 57
 58        data_id - If return_das_record, use this as the records' data_id
 59
 60        use_wss -     If True, use secure websockets
 61
 62        check_cert  - If True and use_wss is True, check the server's TLS certificate
 63                      for validity; if a str, use as local filepath location of .pem
 64                      file to check against.
 65        ```
 66        When invoked in a config file, this would be:
 67        ```
 68          readers:
 69            class: CachedDataServer
 70            kwargs:
 71              data_server: localhost:8766
 72              subscription:
 73                fields:
 74                  S330CourseTrue:
 75                    seconds: 0
 76                  S330HeadingTrue:
 77                    seconds: 0
 78        ```
 79        """
 80        super().__init__(**kwargs)
 81
 82        if not WEBSOCKETS_ENABLED:
 83            raise ModuleNotFoundError('CachedDataReader(): websockets module is not '
 84                                      'installed. Please try "pip3 install '
 85                                      'websockets" prior to use.')
 86        if not (isinstance(bundle_seconds, int) or isinstance(bundle_seconds, float)) \
 87                or bundle_seconds < 0:
 88            raise ValueError('CachedDataReader parameter "bundle_seconds" must be a number '
 89                             f'greater than or equal to zero. Found "{bundle_seconds}"')
 90
 91        # To simplify templating, subscription may be a list of fields instead of a dict.
 92        # If so, convert it to a dict here.from
 93        subscription_fields = subscription.get('fields')
 94        if not subscription_fields:
 95            raise ValueError('CachedDataReader subscription - no "fields" found!')
 96        elif isinstance(subscription_fields, list):
 97            new_fields = {field: {'seconds': 0} for field in subscription_fields}
 98            subscription['fields'] = new_fields
 99
100        self.subscription = subscription
101        subscription['type'] = 'subscribe'
102        self.data_server = data_server
103        self.bundle_seconds = bundle_seconds
104        self.return_das_record = return_das_record
105        self.data_id = data_id
106        self.use_wss = use_wss
107        self.check_cert = check_cert
108
109        # We won't initialize our websocket until the first read()
110        # call. At that point we'll launch an async process in a separate
111        # thread that will wait for data from the websocket and put it in
112        # a queue that read() will pop from.
113        self.websocket_thread = None
114        self.queue = queue.Queue()
115        self.quit_flag = False
subscription - a dictionary corresponding to the full
    fields/seconds, etc that the reader wishes, following the
    conventions described in logger/utils/cached_data_server.py
    e.g:

    subscription = {'fields':{'S330CourseTrue':{seconds:0},
                              'S330HeadingTrue':{seconds:0}}}

    If the value of 'fields' is a list instead of a dict, it will be
    interpreted as a list of field names to be subscribed to with a
    value of seconds = 0. e.g.:

    subscription = {'fields':['S330CourseTrue', 'S330HeadingTrue']}

data_server - the host and port at which to try to connect to a
    CachedDataServer

bundle_seconds - If specified, aggregate this many seconds worth of records
              and return as a list of records. Note that as implemented, it
              bundles by system clock time, and not by DASRecord timestamp.

return_das_record - If True, return results as DASRecords.

data_id - If return_das_record, use this as the records' data_id

use_wss -     If True, use secure websockets

check_cert  - If True and use_wss is True, check the server's TLS certificate
              for validity; if a str, use as local filepath location of .pem
              file to check against.

When invoked in a config file, this would be:

  readers:
    class: CachedDataServer
    kwargs:
      data_server: localhost:8766
      subscription:
        fields:
          S330CourseTrue:
            seconds: 0
          S330HeadingTrue:
            seconds: 0
subscription
data_server
bundle_seconds
return_das_record
data_id
use_wss
check_cert
websocket_thread
queue
quit_flag
def quit(self, seconds=0):
238    def quit(self, seconds=0):
239        """Sleep N seconds, then signal quit."""
240        time.sleep(seconds)
241        self.quit_flag = True

Sleep N seconds, then signal quit.

def read(self):
244    def read(self):
245        """Read/wait for data from the websocket."""
246
247        # If we've not yet fired up the websocket thread, do that now.
248        if not self.websocket_thread:
249            self.websocket_thread = threading.Thread(
250                name='websocket_thread',
251                target=self._start_websocket,
252                daemon=True)
253            self.websocket_thread.start()
254
255        start_time = time.time()
256        result_list = []
257
258        # Use a timeout in our queue get() so we can periodically check if
259        # we've gotten a 'quit'
260        while not self.quit_flag:
261            try:
262                result = self.queue.get(timeout=1)
263                logging.debug('Got result from queue: %s', result)
264
265                # If we're not bundling results, just return the result
266                if not self.bundle_seconds:
267                    if self.return_das_record:
268                        result = to_das_record_list(result, data_id=self.data_id)
269                    return result
270                else:
271                    result_list.append(result)
272            except queue.Empty:
273                logging.debug('get() timed out - trying again')
274                pass
275
276            # If we've been bundling long enough, return the list of records,
277            # or None if the list is empty.
278            now = time.time()
279            if now > start_time + self.bundle_seconds:
280                if self.return_das_record:
281                    result_list = to_das_record_list(result_list, data_id=self.data_id)
282                return result_list
283
284        # If we've fallen out because of a quit...
285        return None

Read/wait for data from the websocket.