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()
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.
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
float: Safety factor to apply around outgoing signal bandwith, defaults to 1.25
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
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
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.
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.
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