pyjs8call.offsetmonitor

Monitor offset frequency based on activity in the pass band.

The offset frequency is automatically moved to an unused portion of the pass band if a recently heard signal overlaps with the current offset. Signal bandwidth is calculated based on the speed of each heard signal.

Only decoded signal data is available from the JS8Call API, so other QRM cannot be handled.

  1# MIT License
  2# 
  3# Copyright (c) 2022-2023 Simply Equipped
  4# 
  5# Permission is hereby granted, free of charge, to any person obtaining a copy
  6# of this software and associated documentation files (the "Software"), to deal
  7# in the Software without restriction, including without limitation the rights
  8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  9# copies of the Software, and to permit persons to whom the Software is
 10# furnished to do so, subject to the following conditions:
 11# 
 12# The above copyright notice and this permission notice shall be included in all
 13# copies or substantial portions of the Software.
 14# 
 15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 21# SOFTWARE.
 22
 23'''Monitor offset frequency based on activity in the pass band.
 24
 25The offset frequency is automatically moved to an unused portion of the pass band if a recently heard signal overlaps with the current offset. Signal bandwidth is calculated based on the speed of each heard signal.
 26
 27Only decoded signal data is available from the JS8Call API, so other QRM cannot be handled.
 28'''
 29
 30__docformat__ = 'google'
 31
 32
 33import threading
 34import random
 35import time
 36
 37from pyjs8call import Message
 38
 39
 40class OffsetMonitor:
 41    '''Monitor offset frequency based on activity in the pass band.'''
 42
 43    def __init__(self, client, hb=False):
 44        '''Initialize offset monitor.
 45
 46        Args:
 47            client (pyjs8call.client): Parent client object
 48            hb (bool): Whether offset monitor is for heartbeat monitoring, defaults to False
 49
 50        Returns:
 51            pyjs8call.offsetmonitor: Constructed offset monitor object
 52        '''
 53        self._client = client
 54        self.min_offset = 1000
 55        '''int: Minimum offset for adjustment and recent activity monitoring, defaults to 1000'''
 56        self.max_offset = 2500
 57        '''int: Maximum offset for adjustment and recent activity monitoring, defaults to 2500'''
 58        self.activity_cycles = 2.5
 59        '''int, float: rx/tx cycles to consider recent activity, defaults to 2.5'''
 60        self.bandwidth_safety_factor = 1.25
 61        '''float: Safety factor to apply around outgoing signal bandwith, defaults to 1.25'''
 62        self._bandwidth = self._client.settings.get_bandwidth()
 63        self._offset = self._client.settings.get_offset()
 64        self._enabled = False
 65        self._paused = False
 66        self._hb = hb
 67
 68        self._recent_signals = []
 69        self._recent_signals_lock = threading.Lock()
 70
 71    def enabled(self):
 72        '''Get enabled status.
 73        
 74        Returns:
 75            bool: True if enabled, False if disabled
 76        '''
 77        return self._enabled
 78
 79    def paused(self):
 80        '''Get paused status.
 81        
 82        Returns:
 83            bool: True if paused, False if running
 84        '''
 85        return self._paused
 86
 87    def enable(self):
 88        '''Enable offset monitoring.'''
 89        if self._enabled:
 90            return
 91
 92        self._enabled = True
 93        self._client.callback.register_incoming(self.process_rx_activity, message_type = Message.RX_ACTIVITY)
 94
 95        thread = threading.Thread(target=self._monitor)
 96        thread.daemon = True
 97        thread.start()
 98
 99    def disable(self):
100        '''Disable offset monitoring.'''
101        self._enabled = False
102        self._client.callback.remove_incoming(self.process_rx_activity)
103
104    def pause(self):
105        '''Pause offset monitoring.'''
106        self._paused = True
107
108    def resume(self):
109        '''Resume offset monitoring.'''
110        self._paused = False
111
112    def process_rx_activity(self, activity):
113        '''Process recent incoming activity.
114
115        Note: This function is called internally when activity is received.
116
117        Args:
118            object (pyjs8call.Message): RX.ACTIVITY message from JS8Call
119        '''
120        # ignore activity outside the specified pass band
121        if activity.offset < self.min_offset or activity.offset > self.max_offset:
122            return
123            
124        if activity.speed is None:
125            # assume worst case bandwidth: turbo mode = 160 Hz
126            signal = (activity.offset, 160, activity.timestamp)
127        else:
128            # map signal speed to signal bandwidth
129            bandwidth = self._client.settings.get_bandwidth(speed = activity.speed)
130            signal = (activity.offset, bandwidth, activity.timestamp)
131
132        with self._recent_signals_lock:
133            self._recent_signals.append(signal)
134
135    def _min_signal_freq(self, offset, bandwidth, timestamp=0):
136        '''Get lower edge of signal.
137
138        Args:
139            offset (int): Signal offset frequency in Hz
140            bandwidth (int): Signal bandwidth in Hz
141            timestamp (float): Received timestamp in seconds, defaults to 0
142
143        Returns:
144            int: Minimum frequency of the signal in Hz
145        '''
146        # bandwidth unused, included to support expanding tuple into args
147        return int(offset)
148
149    def _max_signal_freq(self, offset, bandwidth, timestamp=0):
150        '''Get upper edge of signal.
151
152        Args:
153            offset (int): Signal offset frequency in Hz
154            bandwidth (int): Signal bandwidth in Hz
155            timestamp (float): Received timestamp in seconds, defaults to 0
156
157        Returns:
158            int: Maximum frequency of the signal in Hz
159        '''
160        return int(offset + bandwidth)
161
162    def _activity_overlapping(self, activity):
163        '''Check for received signals overlapping station transmit region.
164
165        Args:
166            activity (list): Recent activity signal data
167
168        Returns:
169            bool: True if a recently received signal is overlapping, False otherwise
170        '''
171        if len(activity) == 0:
172            return False
173        
174        for signal in activity:
175            if self._signal_overlapping(*signal):
176                return True
177
178        return False
179
180    def _signal_overlapping(self, offset, bandwidth, timestamp=0):
181        '''Determine if signal overlaps with current offset.
182
183        Args:
184            offset (int): Signal offset frequency in Hz
185            bandwidth (int): Signal bandwidth in Hz
186            timestamp (float): Received timestamp in seconds, defaults to 0
187
188        Returns:
189            bool: Whether the given signal overlaps with the transmit signal space
190        '''
191        # get min/max frequencies
192        other_min_freq = self._min_signal_freq(offset, bandwidth)
193        other_max_freq = self._max_signal_freq(offset, bandwidth)
194        other_center_freq = ((other_max_freq - other_min_freq) / 2) + other_min_freq
195        own_min_freq = self._min_signal_freq(self._offset, self._bandwidth)
196        own_max_freq = self._max_signal_freq(self._offset, self._bandwidth)
197
198        # signal center freq within transmit bandwidth
199        inside = bool(own_min_freq < other_center_freq < own_max_freq)
200        # signal overlapping from above
201        above = bool(own_min_freq < other_min_freq < own_max_freq)
202        # signal overlapping from below
203        below = bool(own_min_freq < other_max_freq < own_max_freq)
204
205        return any([inside, above, below])
206
207    def _find_unused_spectrum(self, signals):
208        '''Find available pass band sections.
209
210        Available sections of the pass band are sections that are wide enough for a transmitted signal based on the configured bandwidth (i.e. configured JS8Call modem speed) plus a safety margin. The returned tuples represent the lower and upper limits of available pass band sections.
211
212        Args:
213            signals (list): List of signal tuples
214
215        Returns:
216            list: A list of tuples of the following structure: (lower_freq, upper_freq)
217        '''
218        unused_spectrum = []
219
220        for i in range(len(signals)):
221            min_signal_freq = self._min_signal_freq(*signals[i])
222            max_signal_freq = self._max_signal_freq(*signals[i])
223            lower_limit_below = None
224            upper_limit_below = None
225            lower_limit_above = None
226            upper_limit_above = None
227
228            # signal outside min/max offset range
229            if max_signal_freq < self.min_offset or min_signal_freq > self.max_offset:
230                continue
231
232            # only one signal
233            if len(signals) ==  1:
234                # use minimum offset as lower edge of unused section
235                lower_limit_below = self.min_offset
236                # use current signal's lower edge as upper edge of unused section
237                upper_limit_below = min_signal_freq
238                # use current signal's upper edge as lower edge of unused section
239                lower_limit_above = max_signal_freq
240                # use maximum offset as upper edge of unused section
241                upper_limit_above = self.max_offset
242                
243            # first signal in list
244            elif i == 0:
245                # use minimum offset as lower edge of unused section
246                lower_limit_below = self.min_offset
247                # use current signal's lower edge as upper edge of unused section
248                upper_limit_below = min_signal_freq
249
250            # last signal in list
251            elif i == len(signals) - 1:
252                # use previous signal's upper edge as lower edge of unused section
253                lower_limit_below = self._max_signal_freq(*signals[i-1])
254                # use current signal's lower edge as upper edge of unused section
255                upper_limit_below = min_signal_freq
256                # use current signal's upper edge as lower edge of unused section
257                lower_limit_above = max_signal_freq
258                # use maximum offset as upper edge of unused section
259                upper_limit_above = self.max_offset
260
261            # signal somwhere else in the list
262            else:
263                # use previous signal's upper edge as lower edge of unused section
264                lower_limit_below = self._max_signal_freq(*signals[i-1])
265                # use current signal's lower edge as upper edge of unused section
266                upper_limit_below = min_signal_freq
267
268
269            safe_bandwidth = self._bandwidth * self.bandwidth_safety_factor
270            
271            # unused section below is wide enough for current speed setting
272            if (
273                lower_limit_below is not None and
274                upper_limit_below is not None and
275                (upper_limit_below - lower_limit_below) >= safe_bandwidth
276            ):
277                unused_spectrum.append( (lower_limit_below, upper_limit_below) )
278
279            # unused section above is wide enough for current speed setting
280            if (
281                lower_limit_above is not None and
282                upper_limit_above is not None and
283                (upper_limit_above - lower_limit_above) >= safe_bandwidth
284            ):
285                unused_spectrum.append( (lower_limit_above, upper_limit_above) )
286
287        return unused_spectrum
288
289    def _find_new_offset(self, activity):
290        '''Get new offset frequency.
291
292        Find a new offset based on available sections in the pass band. The new offset is always moved to the closed available section of the pass band.
293
294        Args:
295            signals (list): List of signal tuples
296
297        Returns:
298            int: New offset frequency in Hz
299            None: no unused specturm is available
300        '''
301        # find unused spectrum (between heard signals)
302        unused_spectrum = self._find_unused_spectrum(activity)
303
304        if len(unused_spectrum) == 0:
305            return None
306            
307        # calculate distance from the current offset to each unused section
308        distance = []
309
310        # keep track of unused_spectrum position after distance sort
311        i = 0
312        for lower_limit, upper_limit in unused_spectrum:
313            # distance tuple index 0 = unused spectrum index
314            # distance tuple index 1 = distance from current offset
315            # distance tuple index 2 = direction from current offset
316            if upper_limit <= (self._offset + self._bandwidth):
317                # below the current offset
318                distance.append( (i, self._offset - upper_limit, 'down') )
319            elif lower_limit >= self._offset:
320                # above the current offset
321                distance.append( (i, lower_limit - self._offset, 'up') )
322
323            i += 1
324
325        if len(distance) == 0:
326            return None
327
328        # sort by distance from current offset
329        distance.sort(key = lambda dist: dist[1])
330        # index of nearest unused spectrum
331        nearest = distance[0][0]
332        # direction to nearest unused spectrum from current offset
333        direction = distance[0][2]
334        # nearest unused section limits
335        lower_limit = unused_spectrum[nearest][0]
336        upper_limit = unused_spectrum[nearest][1]
337        safe_bandwidth = self._bandwidth * self.bandwidth_safety_factor
338
339        if direction == 'up':
340            # move offset up the spectrum to the beginning of the next unused section
341            return int(lower_limit + (safe_bandwidth - self._bandwidth))
342        elif direction == 'down':
343            # move offset down the spectrum to the end of the next unused section
344            return int(upper_limit - safe_bandwidth)
345        else:
346            return None
347
348    def _cull_recent_activity(self):
349        '''Remove aged signal activity.
350
351        Must be called from within self._recent_signals_lock context.
352        '''
353        recent_signals = []
354        offsets = []
355        max_age = int(self.activity_cycles * self._client.settings.get_window_duration())
356        
357        # sort recent signals descending by timestamp,
358        # causes the most recent activity on the same offset to be kept while culling
359        self._recent_signals.sort(key = lambda signal: signal[2], reverse = True)
360        
361        now = time.time()
362        
363        for signal in self._recent_signals:
364            if signal[0] not in offsets and now - signal[2] <= max_age:
365                # keep recent signals with a unique offset
366                recent_signals.append(signal)
367                offsets.append(signal[0])
368                
369        # sort ascending by offset
370        recent_signals.sort(key = lambda signal: signal[0])
371        self._recent_signals = recent_signals
372    
373    def _monitor(self):
374        '''Offset monitor thread.
375
376        Check recent activity just before the end of the current tx window. This allows a new offset to be selected before the next rx/tx window if new activity overlaps with the transmit region. The offset is not updated if a message is being sent (i.e. there is text in the tx text box).
377        '''
378        while self._enabled:
379            # wait until just before the end of the rx/tx window
380            self._client.window.sleep_until_next_transition(before = 1)
381            new_offset = None
382
383            if self._paused:
384                continue
385
386            # skip processing if actively sending a message
387            if self._client.js8call.activity():
388                continue
389
390            # get current settings
391            self._bandwidth = self._client.settings.get_bandwidth()
392            self._offset = self._client.settings.get_offset()
393
394            # force offset into specified pass band
395            if self._offset < self.min_offset or self._offset > (self.max_offset - self._bandwidth):
396                if self._hb:
397                    # random offset in heartbeat sub-band
398                    self._offset = random.randrange(self.min_offset, self.max_offset - self._bandwidth)
399                else:
400                    # middle of pass band
401                    self._offset = ((self.max_offset - self.min_offset) / 2) + self.min_offset
402
403            with self._recent_signals_lock:
404                self._cull_recent_activity()
405                # check for signal overlap with transmit region
406                if self._activity_overlapping(self._recent_signals):
407                    new_offset = self._find_new_offset(self._recent_signals)
408
409            if new_offset is not None:
410                # set new offset
411                self._offset = self._client.settings.set_offset(new_offset)
412            elif self._offset != self._client.settings.get_offset():
413                # offset out of sync, js8call offset forced into specified band,
414                # typically caused by hb monitor vs offset monitor offset handling
415                self._offset = self._client.settings.set_offset(self._offset)
416
417            # loop runs before the end of the window, wait until the end of the window to ensure loop only runs once
418            self._client.window.sleep_until_next_transition()
class OffsetMonitor:
 41class OffsetMonitor:
 42    '''Monitor offset frequency based on activity in the pass band.'''
 43
 44    def __init__(self, client, hb=False):
 45        '''Initialize offset monitor.
 46
 47        Args:
 48            client (pyjs8call.client): Parent client object
 49            hb (bool): Whether offset monitor is for heartbeat monitoring, defaults to False
 50
 51        Returns:
 52            pyjs8call.offsetmonitor: Constructed offset monitor object
 53        '''
 54        self._client = client
 55        self.min_offset = 1000
 56        '''int: Minimum offset for adjustment and recent activity monitoring, defaults to 1000'''
 57        self.max_offset = 2500
 58        '''int: Maximum offset for adjustment and recent activity monitoring, defaults to 2500'''
 59        self.activity_cycles = 2.5
 60        '''int, float: rx/tx cycles to consider recent activity, defaults to 2.5'''
 61        self.bandwidth_safety_factor = 1.25
 62        '''float: Safety factor to apply around outgoing signal bandwith, defaults to 1.25'''
 63        self._bandwidth = self._client.settings.get_bandwidth()
 64        self._offset = self._client.settings.get_offset()
 65        self._enabled = False
 66        self._paused = False
 67        self._hb = hb
 68
 69        self._recent_signals = []
 70        self._recent_signals_lock = threading.Lock()
 71
 72    def enabled(self):
 73        '''Get enabled status.
 74        
 75        Returns:
 76            bool: True if enabled, False if disabled
 77        '''
 78        return self._enabled
 79
 80    def paused(self):
 81        '''Get paused status.
 82        
 83        Returns:
 84            bool: True if paused, False if running
 85        '''
 86        return self._paused
 87
 88    def enable(self):
 89        '''Enable offset monitoring.'''
 90        if self._enabled:
 91            return
 92
 93        self._enabled = True
 94        self._client.callback.register_incoming(self.process_rx_activity, message_type = Message.RX_ACTIVITY)
 95
 96        thread = threading.Thread(target=self._monitor)
 97        thread.daemon = True
 98        thread.start()
 99
100    def disable(self):
101        '''Disable offset monitoring.'''
102        self._enabled = False
103        self._client.callback.remove_incoming(self.process_rx_activity)
104
105    def pause(self):
106        '''Pause offset monitoring.'''
107        self._paused = True
108
109    def resume(self):
110        '''Resume offset monitoring.'''
111        self._paused = False
112
113    def process_rx_activity(self, activity):
114        '''Process recent incoming activity.
115
116        Note: This function is called internally when activity is received.
117
118        Args:
119            object (pyjs8call.Message): RX.ACTIVITY message from JS8Call
120        '''
121        # ignore activity outside the specified pass band
122        if activity.offset < self.min_offset or activity.offset > self.max_offset:
123            return
124            
125        if activity.speed is None:
126            # assume worst case bandwidth: turbo mode = 160 Hz
127            signal = (activity.offset, 160, activity.timestamp)
128        else:
129            # map signal speed to signal bandwidth
130            bandwidth = self._client.settings.get_bandwidth(speed = activity.speed)
131            signal = (activity.offset, bandwidth, activity.timestamp)
132
133        with self._recent_signals_lock:
134            self._recent_signals.append(signal)
135
136    def _min_signal_freq(self, offset, bandwidth, timestamp=0):
137        '''Get lower edge of signal.
138
139        Args:
140            offset (int): Signal offset frequency in Hz
141            bandwidth (int): Signal bandwidth in Hz
142            timestamp (float): Received timestamp in seconds, defaults to 0
143
144        Returns:
145            int: Minimum frequency of the signal in Hz
146        '''
147        # bandwidth unused, included to support expanding tuple into args
148        return int(offset)
149
150    def _max_signal_freq(self, offset, bandwidth, timestamp=0):
151        '''Get upper edge of signal.
152
153        Args:
154            offset (int): Signal offset frequency in Hz
155            bandwidth (int): Signal bandwidth in Hz
156            timestamp (float): Received timestamp in seconds, defaults to 0
157
158        Returns:
159            int: Maximum frequency of the signal in Hz
160        '''
161        return int(offset + bandwidth)
162
163    def _activity_overlapping(self, activity):
164        '''Check for received signals overlapping station transmit region.
165
166        Args:
167            activity (list): Recent activity signal data
168
169        Returns:
170            bool: True if a recently received signal is overlapping, False otherwise
171        '''
172        if len(activity) == 0:
173            return False
174        
175        for signal in activity:
176            if self._signal_overlapping(*signal):
177                return True
178
179        return False
180
181    def _signal_overlapping(self, offset, bandwidth, timestamp=0):
182        '''Determine if signal overlaps with current offset.
183
184        Args:
185            offset (int): Signal offset frequency in Hz
186            bandwidth (int): Signal bandwidth in Hz
187            timestamp (float): Received timestamp in seconds, defaults to 0
188
189        Returns:
190            bool: Whether the given signal overlaps with the transmit signal space
191        '''
192        # get min/max frequencies
193        other_min_freq = self._min_signal_freq(offset, bandwidth)
194        other_max_freq = self._max_signal_freq(offset, bandwidth)
195        other_center_freq = ((other_max_freq - other_min_freq) / 2) + other_min_freq
196        own_min_freq = self._min_signal_freq(self._offset, self._bandwidth)
197        own_max_freq = self._max_signal_freq(self._offset, self._bandwidth)
198
199        # signal center freq within transmit bandwidth
200        inside = bool(own_min_freq < other_center_freq < own_max_freq)
201        # signal overlapping from above
202        above = bool(own_min_freq < other_min_freq < own_max_freq)
203        # signal overlapping from below
204        below = bool(own_min_freq < other_max_freq < own_max_freq)
205
206        return any([inside, above, below])
207
208    def _find_unused_spectrum(self, signals):
209        '''Find available pass band sections.
210
211        Available sections of the pass band are sections that are wide enough for a transmitted signal based on the configured bandwidth (i.e. configured JS8Call modem speed) plus a safety margin. The returned tuples represent the lower and upper limits of available pass band sections.
212
213        Args:
214            signals (list): List of signal tuples
215
216        Returns:
217            list: A list of tuples of the following structure: (lower_freq, upper_freq)
218        '''
219        unused_spectrum = []
220
221        for i in range(len(signals)):
222            min_signal_freq = self._min_signal_freq(*signals[i])
223            max_signal_freq = self._max_signal_freq(*signals[i])
224            lower_limit_below = None
225            upper_limit_below = None
226            lower_limit_above = None
227            upper_limit_above = None
228
229            # signal outside min/max offset range
230            if max_signal_freq < self.min_offset or min_signal_freq > self.max_offset:
231                continue
232
233            # only one signal
234            if len(signals) ==  1:
235                # use minimum offset as lower edge of unused section
236                lower_limit_below = self.min_offset
237                # use current signal's lower edge as upper edge of unused section
238                upper_limit_below = min_signal_freq
239                # use current signal's upper edge as lower edge of unused section
240                lower_limit_above = max_signal_freq
241                # use maximum offset as upper edge of unused section
242                upper_limit_above = self.max_offset
243                
244            # first signal in list
245            elif i == 0:
246                # use minimum offset as lower edge of unused section
247                lower_limit_below = self.min_offset
248                # use current signal's lower edge as upper edge of unused section
249                upper_limit_below = min_signal_freq
250
251            # last signal in list
252            elif i == len(signals) - 1:
253                # use previous signal's upper edge as lower edge of unused section
254                lower_limit_below = self._max_signal_freq(*signals[i-1])
255                # use current signal's lower edge as upper edge of unused section
256                upper_limit_below = min_signal_freq
257                # use current signal's upper edge as lower edge of unused section
258                lower_limit_above = max_signal_freq
259                # use maximum offset as upper edge of unused section
260                upper_limit_above = self.max_offset
261
262            # signal somwhere else in the list
263            else:
264                # use previous signal's upper edge as lower edge of unused section
265                lower_limit_below = self._max_signal_freq(*signals[i-1])
266                # use current signal's lower edge as upper edge of unused section
267                upper_limit_below = min_signal_freq
268
269
270            safe_bandwidth = self._bandwidth * self.bandwidth_safety_factor
271            
272            # unused section below is wide enough for current speed setting
273            if (
274                lower_limit_below is not None and
275                upper_limit_below is not None and
276                (upper_limit_below - lower_limit_below) >= safe_bandwidth
277            ):
278                unused_spectrum.append( (lower_limit_below, upper_limit_below) )
279
280            # unused section above is wide enough for current speed setting
281            if (
282                lower_limit_above is not None and
283                upper_limit_above is not None and
284                (upper_limit_above - lower_limit_above) >= safe_bandwidth
285            ):
286                unused_spectrum.append( (lower_limit_above, upper_limit_above) )
287
288        return unused_spectrum
289
290    def _find_new_offset(self, activity):
291        '''Get new offset frequency.
292
293        Find a new offset based on available sections in the pass band. The new offset is always moved to the closed available section of the pass band.
294
295        Args:
296            signals (list): List of signal tuples
297
298        Returns:
299            int: New offset frequency in Hz
300            None: no unused specturm is available
301        '''
302        # find unused spectrum (between heard signals)
303        unused_spectrum = self._find_unused_spectrum(activity)
304
305        if len(unused_spectrum) == 0:
306            return None
307            
308        # calculate distance from the current offset to each unused section
309        distance = []
310
311        # keep track of unused_spectrum position after distance sort
312        i = 0
313        for lower_limit, upper_limit in unused_spectrum:
314            # distance tuple index 0 = unused spectrum index
315            # distance tuple index 1 = distance from current offset
316            # distance tuple index 2 = direction from current offset
317            if upper_limit <= (self._offset + self._bandwidth):
318                # below the current offset
319                distance.append( (i, self._offset - upper_limit, 'down') )
320            elif lower_limit >= self._offset:
321                # above the current offset
322                distance.append( (i, lower_limit - self._offset, 'up') )
323
324            i += 1
325
326        if len(distance) == 0:
327            return None
328
329        # sort by distance from current offset
330        distance.sort(key = lambda dist: dist[1])
331        # index of nearest unused spectrum
332        nearest = distance[0][0]
333        # direction to nearest unused spectrum from current offset
334        direction = distance[0][2]
335        # nearest unused section limits
336        lower_limit = unused_spectrum[nearest][0]
337        upper_limit = unused_spectrum[nearest][1]
338        safe_bandwidth = self._bandwidth * self.bandwidth_safety_factor
339
340        if direction == 'up':
341            # move offset up the spectrum to the beginning of the next unused section
342            return int(lower_limit + (safe_bandwidth - self._bandwidth))
343        elif direction == 'down':
344            # move offset down the spectrum to the end of the next unused section
345            return int(upper_limit - safe_bandwidth)
346        else:
347            return None
348
349    def _cull_recent_activity(self):
350        '''Remove aged signal activity.
351
352        Must be called from within self._recent_signals_lock context.
353        '''
354        recent_signals = []
355        offsets = []
356        max_age = int(self.activity_cycles * self._client.settings.get_window_duration())
357        
358        # sort recent signals descending by timestamp,
359        # causes the most recent activity on the same offset to be kept while culling
360        self._recent_signals.sort(key = lambda signal: signal[2], reverse = True)
361        
362        now = time.time()
363        
364        for signal in self._recent_signals:
365            if signal[0] not in offsets and now - signal[2] <= max_age:
366                # keep recent signals with a unique offset
367                recent_signals.append(signal)
368                offsets.append(signal[0])
369                
370        # sort ascending by offset
371        recent_signals.sort(key = lambda signal: signal[0])
372        self._recent_signals = recent_signals
373    
374    def _monitor(self):
375        '''Offset monitor thread.
376
377        Check recent activity just before the end of the current tx window. This allows a new offset to be selected before the next rx/tx window if new activity overlaps with the transmit region. The offset is not updated if a message is being sent (i.e. there is text in the tx text box).
378        '''
379        while self._enabled:
380            # wait until just before the end of the rx/tx window
381            self._client.window.sleep_until_next_transition(before = 1)
382            new_offset = None
383
384            if self._paused:
385                continue
386
387            # skip processing if actively sending a message
388            if self._client.js8call.activity():
389                continue
390
391            # get current settings
392            self._bandwidth = self._client.settings.get_bandwidth()
393            self._offset = self._client.settings.get_offset()
394
395            # force offset into specified pass band
396            if self._offset < self.min_offset or self._offset > (self.max_offset - self._bandwidth):
397                if self._hb:
398                    # random offset in heartbeat sub-band
399                    self._offset = random.randrange(self.min_offset, self.max_offset - self._bandwidth)
400                else:
401                    # middle of pass band
402                    self._offset = ((self.max_offset - self.min_offset) / 2) + self.min_offset
403
404            with self._recent_signals_lock:
405                self._cull_recent_activity()
406                # check for signal overlap with transmit region
407                if self._activity_overlapping(self._recent_signals):
408                    new_offset = self._find_new_offset(self._recent_signals)
409
410            if new_offset is not None:
411                # set new offset
412                self._offset = self._client.settings.set_offset(new_offset)
413            elif self._offset != self._client.settings.get_offset():
414                # offset out of sync, js8call offset forced into specified band,
415                # typically caused by hb monitor vs offset monitor offset handling
416                self._offset = self._client.settings.set_offset(self._offset)
417
418            # loop runs before the end of the window, wait until the end of the window to ensure loop only runs once
419            self._client.window.sleep_until_next_transition()

Monitor offset frequency based on activity in the pass band.

OffsetMonitor(client, hb=False)
44    def __init__(self, client, hb=False):
45        '''Initialize offset monitor.
46
47        Args:
48            client (pyjs8call.client): Parent client object
49            hb (bool): Whether offset monitor is for heartbeat monitoring, defaults to False
50
51        Returns:
52            pyjs8call.offsetmonitor: Constructed offset monitor object
53        '''
54        self._client = client
55        self.min_offset = 1000
56        '''int: Minimum offset for adjustment and recent activity monitoring, defaults to 1000'''
57        self.max_offset = 2500
58        '''int: Maximum offset for adjustment and recent activity monitoring, defaults to 2500'''
59        self.activity_cycles = 2.5
60        '''int, float: rx/tx cycles to consider recent activity, defaults to 2.5'''
61        self.bandwidth_safety_factor = 1.25
62        '''float: Safety factor to apply around outgoing signal bandwith, defaults to 1.25'''
63        self._bandwidth = self._client.settings.get_bandwidth()
64        self._offset = self._client.settings.get_offset()
65        self._enabled = False
66        self._paused = False
67        self._hb = hb
68
69        self._recent_signals = []
70        self._recent_signals_lock = threading.Lock()

Initialize offset monitor.

Arguments:
  • client (pyjs8call.client): Parent client object
  • hb (bool): Whether offset monitor is for heartbeat monitoring, defaults to False
Returns:

pyjs8call.offsetmonitor: Constructed offset monitor object

min_offset

int: Minimum offset for adjustment and recent activity monitoring, defaults to 1000

max_offset

int: Maximum offset for adjustment and recent activity monitoring, defaults to 2500

activity_cycles

int, float: rx/tx cycles to consider recent activity, defaults to 2.5

bandwidth_safety_factor

float: Safety factor to apply around outgoing signal bandwith, defaults to 1.25

def enabled(self):
72    def enabled(self):
73        '''Get enabled status.
74        
75        Returns:
76            bool: True if enabled, False if disabled
77        '''
78        return self._enabled

Get enabled status.

Returns:

bool: True if enabled, False if disabled

def paused(self):
80    def paused(self):
81        '''Get paused status.
82        
83        Returns:
84            bool: True if paused, False if running
85        '''
86        return self._paused

Get paused status.

Returns:

bool: True if paused, False if running

def enable(self):
88    def enable(self):
89        '''Enable offset monitoring.'''
90        if self._enabled:
91            return
92
93        self._enabled = True
94        self._client.callback.register_incoming(self.process_rx_activity, message_type = Message.RX_ACTIVITY)
95
96        thread = threading.Thread(target=self._monitor)
97        thread.daemon = True
98        thread.start()

Enable offset monitoring.

def disable(self):
100    def disable(self):
101        '''Disable offset monitoring.'''
102        self._enabled = False
103        self._client.callback.remove_incoming(self.process_rx_activity)

Disable offset monitoring.

def pause(self):
105    def pause(self):
106        '''Pause offset monitoring.'''
107        self._paused = True

Pause offset monitoring.

def resume(self):
109    def resume(self):
110        '''Resume offset monitoring.'''
111        self._paused = False

Resume offset monitoring.

def process_rx_activity(self, activity):
113    def process_rx_activity(self, activity):
114        '''Process recent incoming activity.
115
116        Note: This function is called internally when activity is received.
117
118        Args:
119            object (pyjs8call.Message): RX.ACTIVITY message from JS8Call
120        '''
121        # ignore activity outside the specified pass band
122        if activity.offset < self.min_offset or activity.offset > self.max_offset:
123            return
124            
125        if activity.speed is None:
126            # assume worst case bandwidth: turbo mode = 160 Hz
127            signal = (activity.offset, 160, activity.timestamp)
128        else:
129            # map signal speed to signal bandwidth
130            bandwidth = self._client.settings.get_bandwidth(speed = activity.speed)
131            signal = (activity.offset, bandwidth, activity.timestamp)
132
133        with self._recent_signals_lock:
134            self._recent_signals.append(signal)

Process recent incoming activity.

Note: This function is called internally when activity is received.

Arguments:
  • object (pyjs8call.Message): RX.ACTIVITY message from JS8Call