aprsd 4.1.1__py3-none-any.whl → 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,408 @@
1
+ """
2
+ APRSD KISS Client Driver using native KISS implementation.
3
+
4
+ This module provides a KISS client driver for APRSD using the new
5
+ non-asyncio KISSInterface implementation.
6
+ """
7
+
8
+ import datetime
9
+ import logging
10
+ import select
11
+ import socket
12
+ import time
13
+ from typing import Any, Callable, Dict
14
+
15
+ import aprslib
16
+ from ax253 import frame as ax25frame
17
+ from kiss import constants as kiss_constants
18
+ from kiss import util as kissutil
19
+ from kiss.kiss import Command
20
+ from oslo_config import cfg
21
+
22
+ from aprsd import ( # noqa
23
+ client,
24
+ conf, # noqa
25
+ exception,
26
+ )
27
+ from aprsd.packets import core
28
+
29
+ CONF = cfg.CONF
30
+ LOG = logging.getLogger('APRSD')
31
+
32
+
33
+ def handle_fend(buffer: bytes, strip_df_start: bool = True) -> bytes:
34
+ """
35
+ Handle FEND (end of frame) encountered in a KISS data stream.
36
+
37
+ :param buffer: the buffer containing the frame
38
+ :param strip_df_start: remove leading null byte (DATA_FRAME opcode)
39
+ :return: the bytes of the frame without escape characters or frame
40
+ end markers (FEND)
41
+ """
42
+ frame = kissutil.recover_special_codes(kissutil.strip_nmea(bytes(buffer)))
43
+ if strip_df_start:
44
+ frame = kissutil.strip_df_start(frame)
45
+ LOG.warning(f'handle_fend {" ".join(f"{b:02X}" for b in bytes(frame))}')
46
+ return bytes(frame)
47
+
48
+
49
+ # class TCPKISSDriver(metaclass=trace.TraceWrapperMetaclass):
50
+ class TCPKISSDriver:
51
+ """APRSD client driver for TCP KISS connections."""
52
+
53
+ # Class level attributes required by Client protocol
54
+ packets_received = 0
55
+ packets_sent = 0
56
+ last_packet_sent = None
57
+ last_packet_received = None
58
+ keepalive = None
59
+ client_name = None
60
+ socket = None
61
+ # timeout in seconds
62
+ select_timeout = 1
63
+ path = None
64
+
65
+ def __init__(self):
66
+ """Initialize the KISS client.
67
+
68
+ Args:
69
+ client_name: Name of the client instance
70
+ """
71
+ super().__init__()
72
+ self._connected = False
73
+ self.keepalive = datetime.datetime.now()
74
+ self._running = False
75
+ # This is initialized in setup_connection()
76
+ self.socket = None
77
+
78
+ @property
79
+ def transport(self) -> str:
80
+ return client.TRANSPORT_TCPKISS
81
+
82
+ @classmethod
83
+ def is_enabled(cls) -> bool:
84
+ """Check if KISS is enabled in configuration.
85
+
86
+ Returns:
87
+ bool: True if either TCP is enabled
88
+ """
89
+ return CONF.kiss_tcp.enabled
90
+
91
+ @staticmethod
92
+ def is_configured():
93
+ # Ensure that the config vars are correctly set
94
+ if TCPKISSDriver.is_enabled():
95
+ if not CONF.kiss_tcp.host:
96
+ LOG.error('KISS TCP enabled, but no host is set.')
97
+ raise exception.MissingConfigOptionException(
98
+ 'kiss_tcp.host is not set.',
99
+ )
100
+ return True
101
+ return False
102
+
103
+ @property
104
+ def is_alive(self) -> bool:
105
+ """Check if the client is connected.
106
+
107
+ Returns:
108
+ bool: True if connected to KISS TNC, False otherwise
109
+ """
110
+ return self._connected
111
+
112
+ def close(self):
113
+ """Close the connection."""
114
+ self.stop()
115
+
116
+ def send(self, packet: core.Packet):
117
+ """Send an APRS packet.
118
+
119
+ Args:
120
+ packet: APRS packet to send (Packet or Message object)
121
+
122
+ Raises:
123
+ Exception: If not connected or send fails
124
+ """
125
+ if not self.socket:
126
+ raise Exception('KISS interface not initialized')
127
+
128
+ payload = None
129
+ path = self.path
130
+ packet.prepare()
131
+ payload = packet.payload.encode('US-ASCII')
132
+ if packet.path:
133
+ path = packet.path
134
+
135
+ LOG.debug(
136
+ f"KISS Send '{payload}' TO '{packet.to_call}' From "
137
+ f"'{packet.from_call}' with PATH '{path}'",
138
+ )
139
+ frame = ax25frame.Frame.ui(
140
+ destination='APZ100',
141
+ # destination=packet.to_call,
142
+ source=packet.from_call,
143
+ path=path,
144
+ info=payload,
145
+ )
146
+
147
+ # now escape the frame special characters
148
+ frame_escaped = kissutil.escape_special_codes(bytes(frame))
149
+ # and finally wrap the frame in KISS protocol
150
+ command = Command.DATA_FRAME
151
+ frame_kiss = b''.join(
152
+ [kiss_constants.FEND, command.value, frame_escaped, kiss_constants.FEND]
153
+ )
154
+ self.socket.send(frame_kiss)
155
+ # Update last packet sent time
156
+ self.last_packet_sent = datetime.datetime.now()
157
+ # Increment packets sent counter
158
+ self.packets_sent += 1
159
+
160
+ def setup_connection(self):
161
+ """Set up the KISS interface."""
162
+ if not self.is_enabled():
163
+ LOG.error('KISS is not enabled in configuration')
164
+ return
165
+
166
+ try:
167
+ # Configure for TCP KISS
168
+ if self.is_enabled():
169
+ LOG.info(
170
+ f'KISS TCP Connection to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port}'
171
+ )
172
+ self.path = CONF.kiss_tcp.path
173
+ self.connect()
174
+ if self._connected:
175
+ LOG.info('KISS interface initialized')
176
+ else:
177
+ LOG.error('Failed to connect to KISS interface')
178
+
179
+ except Exception as ex:
180
+ LOG.error('Failed to initialize KISS interface')
181
+ LOG.exception(ex)
182
+ self._connected = False
183
+
184
+ def set_filter(self, filter_text: str):
185
+ """Set packet filter (not implemented for KISS).
186
+
187
+ Args:
188
+ filter_text: Filter specification (ignored for KISS)
189
+ """
190
+ # KISS doesn't support filtering at the TNC level
191
+ pass
192
+
193
+ @property
194
+ def filter(self) -> str:
195
+ """Get packet filter (not implemented for KISS).
196
+ Returns:
197
+ str: Empty string (not implemented for KISS)
198
+ """
199
+ return ''
200
+
201
+ def login_success(self) -> bool:
202
+ """There is no login for KISS."""
203
+ if not self._connected:
204
+ return False
205
+ return True
206
+
207
+ def login_failure(self) -> str:
208
+ """There is no login for KISS."""
209
+ return 'Login successful'
210
+
211
+ def consumer(self, callback: Callable, raw: bool = False):
212
+ """Start consuming frames with the given callback.
213
+
214
+ Args:
215
+ callback: Function to call with received packets
216
+
217
+ Raises:
218
+ Exception: If not connected to KISS TNC
219
+ """
220
+ self._running = True
221
+ while self._running:
222
+ # Ensure connection
223
+ if not self._connected:
224
+ if not self.connect():
225
+ time.sleep(1)
226
+ continue
227
+
228
+ # Read frame
229
+ frame = self.read_frame()
230
+ if frame:
231
+ LOG.warning(f'GOT FRAME: {frame} calling {callback}')
232
+ kwargs = {
233
+ 'frame': frame,
234
+ }
235
+ callback(**kwargs)
236
+
237
+ def decode_packet(self, *args, **kwargs) -> core.Packet:
238
+ """Decode a packet from an AX.25 frame.
239
+
240
+ Args:
241
+ frame: Received AX.25 frame
242
+ """
243
+ frame = kwargs.get('frame')
244
+ if not frame:
245
+ LOG.warning('No frame received to decode?!?!')
246
+ return None
247
+
248
+ LOG.warning(f'FRAME: {str(frame)}')
249
+ try:
250
+ aprslib_frame = aprslib.parse(str(frame))
251
+ return core.factory(aprslib_frame)
252
+ except Exception as e:
253
+ LOG.error(f'Error decoding packet: {e}')
254
+ return None
255
+
256
+ def stop(self):
257
+ """Stop the KISS interface."""
258
+ self._running = False
259
+ self._connected = False
260
+ if self.socket:
261
+ try:
262
+ self.socket.close()
263
+ except Exception:
264
+ pass
265
+
266
+ def stats(self, serializable: bool = False) -> Dict[str, Any]:
267
+ """Get client statistics.
268
+
269
+ Returns:
270
+ Dict containing client statistics
271
+ """
272
+ if serializable:
273
+ keepalive = self.keepalive.isoformat()
274
+ else:
275
+ keepalive = self.keepalive
276
+ stats = {
277
+ 'client': self.__class__.__name__,
278
+ 'transport': self.transport,
279
+ 'connected': self._connected,
280
+ 'path': self.path,
281
+ 'packets_sent': self.packets_sent,
282
+ 'packets_received': self.packets_received,
283
+ 'last_packet_sent': self.last_packet_sent,
284
+ 'last_packet_received': self.last_packet_received,
285
+ 'connection_keepalive': keepalive,
286
+ 'host': CONF.kiss_tcp.host,
287
+ 'port': CONF.kiss_tcp.port,
288
+ }
289
+
290
+ return stats
291
+
292
+ def connect(self) -> bool:
293
+ """Establish TCP connection to the KISS host.
294
+
295
+ Returns:
296
+ bool: True if connection successful, False otherwise
297
+ """
298
+ try:
299
+ if self.socket:
300
+ try:
301
+ self.socket.close()
302
+ except Exception:
303
+ pass
304
+
305
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
306
+ self.socket.settimeout(5.0) # 5 second timeout for connection
307
+ self.socket.connect((CONF.kiss_tcp.host, CONF.kiss_tcp.port))
308
+ self.socket.settimeout(0.1) # Reset to shorter timeout for reads
309
+ self._connected = True
310
+ self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
311
+ # MACOS doesn't have TCP_KEEPIDLE
312
+ if hasattr(socket, 'TCP_KEEPIDLE'):
313
+ self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 1)
314
+ self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 3)
315
+ self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
316
+ return True
317
+
318
+ except ConnectionError as e:
319
+ LOG.error(
320
+ f'Failed to connect to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port} - {str(e)}'
321
+ )
322
+ self._connected = False
323
+ return False
324
+
325
+ except Exception as e:
326
+ LOG.error(
327
+ f'Failed to connect to {CONF.kiss_tcp.host}:{CONF.kiss_tcp.port} - {str(e)}'
328
+ )
329
+ self._connected = False
330
+ return False
331
+
332
+ def fix_raw_frame(self, raw_frame: bytes) -> bytes:
333
+ """Fix the raw frame by recalculating the FCS."""
334
+ ax25_data = raw_frame[2:-1] # Remove KISS markers
335
+ return handle_fend(ax25_data)
336
+
337
+ def read_frame(self, blocking=False):
338
+ """
339
+ Generator for complete lines, received from the server
340
+ """
341
+ try:
342
+ self.socket.setblocking(0)
343
+ except OSError as e:
344
+ LOG.error(f'socket error when setblocking(0): {str(e)}')
345
+ raise aprslib.ConnectionDrop('connection dropped') from e
346
+
347
+ while self._running:
348
+ short_buf = b''
349
+
350
+ try:
351
+ readable, _, _ = select.select(
352
+ [self.socket],
353
+ [],
354
+ [],
355
+ self.select_timeout,
356
+ )
357
+ if not readable:
358
+ if not blocking:
359
+ break
360
+ else:
361
+ continue
362
+ except Exception as e:
363
+ LOG.error(f'Error in read loop: {e}')
364
+ self._connected = False
365
+ break
366
+
367
+ try:
368
+ print('reading from socket')
369
+ short_buf = self.socket.recv(1024)
370
+ print(f'short_buf: {short_buf}')
371
+ # sock.recv returns empty if the connection drops
372
+ if not short_buf:
373
+ if not blocking:
374
+ # We could just not be blocking, so empty is expected
375
+ continue
376
+ else:
377
+ self.logger.error('socket.recv(): returned empty')
378
+ raise aprslib.ConnectionDrop('connection dropped')
379
+
380
+ raw_frame = self.fix_raw_frame(short_buf)
381
+ return ax25frame.Frame.from_bytes(raw_frame)
382
+ except OSError as e:
383
+ # self.logger.error("socket error on recv(): %s" % str(e))
384
+ if 'Resource temporarily unavailable' in str(e):
385
+ if not blocking:
386
+ if len(short_buf) == 0:
387
+ break
388
+ except socket.timeout:
389
+ continue
390
+ except (KeyboardInterrupt, SystemExit):
391
+ raise
392
+ except ConnectionError:
393
+ self.close()
394
+ if not self.auto_reconnect:
395
+ raise
396
+ else:
397
+ self.connect()
398
+ continue
399
+ except StopIteration:
400
+ break
401
+ except IOError:
402
+ LOG.error('IOError')
403
+ break
404
+ except Exception as e:
405
+ LOG.error(f'Error in read loop: {e}')
406
+ self._connected = False
407
+ if not self.auto_reconnect:
408
+ break
aprsd/client/stats.py CHANGED
@@ -3,7 +3,7 @@ import threading
3
3
  import wrapt
4
4
  from oslo_config import cfg
5
5
 
6
- from aprsd import client
6
+ from aprsd.client.client import APRSDClient
7
7
  from aprsd.utils import singleton
8
8
 
9
9
  CONF = cfg.CONF
@@ -15,4 +15,4 @@ class APRSClientStats:
15
15
 
16
16
  @wrapt.synchronized(lock)
17
17
  def stats(self, serializable=False):
18
- return client.client_factory.create().stats(serializable=serializable)
18
+ return APRSDClient().stats(serializable=serializable)
aprsd/cmds/dev.py CHANGED
@@ -11,7 +11,6 @@ from oslo_config import cfg
11
11
  from aprsd import cli_helper, conf, packets, plugin
12
12
 
13
13
  # local imports here
14
- from aprsd.client import base
15
14
  from aprsd.main import cli
16
15
  from aprsd.utils import trace
17
16
 
@@ -97,8 +96,6 @@ def test_plugin(
97
96
  if CONF.trace_enabled:
98
97
  trace.setup_tracing(['method', 'api'])
99
98
 
100
- base.APRSClient()
101
-
102
99
  pm = plugin.PluginManager()
103
100
  if load_all:
104
101
  pm.setup_plugins(load_help_plugin=CONF.load_help_plugin)
aprsd/cmds/listen.py CHANGED
@@ -17,7 +17,7 @@ from rich.console import Console
17
17
  # local imports here
18
18
  import aprsd
19
19
  from aprsd import cli_helper, packets, plugin, threads, utils
20
- from aprsd.client import client_factory
20
+ from aprsd.client.client import APRSDClient
21
21
  from aprsd.main import cli
22
22
  from aprsd.packets import collector as packet_collector
23
23
  from aprsd.packets import core, seen_list
@@ -232,13 +232,13 @@ def listen(
232
232
  # Initialize the client factory and create
233
233
  # The correct client object ready for use
234
234
  # Make sure we have 1 client transport enabled
235
- if not client_factory.is_client_enabled():
235
+ if not APRSDClient().is_enabled:
236
236
  LOG.error('No Clients are enabled in config.')
237
237
  sys.exit(-1)
238
238
 
239
239
  # Creates the client object
240
240
  LOG.info('Creating client connection')
241
- aprs_client = client_factory.create()
241
+ aprs_client = APRSDClient()
242
242
  LOG.info(aprs_client)
243
243
  if not aprs_client.login_success:
244
244
  # We failed to login, will just quit!
@@ -14,7 +14,7 @@ from aprsd import (
14
14
  conf, # noqa : F401
15
15
  packets,
16
16
  )
17
- from aprsd.client import client_factory
17
+ from aprsd.client.client import APRSDClient
18
18
  from aprsd.main import cli
19
19
  from aprsd.packets import collector
20
20
  from aprsd.packets import log as packet_log
@@ -103,7 +103,7 @@ def send_message(
103
103
 
104
104
  def rx_packet(packet):
105
105
  global got_ack, got_response
106
- cl = client_factory.create()
106
+ cl = APRSDClient()
107
107
  packet = cl.decode_packet(packet)
108
108
  collector.PacketCollector().rx(packet)
109
109
  packet_log.log(packet, tx=False)
@@ -131,7 +131,7 @@ def send_message(
131
131
  sys.exit(0)
132
132
 
133
133
  try:
134
- client_factory.create().client # noqa: B018
134
+ APRSDClient().client # noqa: B018
135
135
  except LoginError:
136
136
  sys.exit(-1)
137
137
 
@@ -163,7 +163,7 @@ def send_message(
163
163
  # This will register a packet consumer with aprslib
164
164
  # When new packets come in the consumer will process
165
165
  # the packet
166
- aprs_client = client_factory.create().client
166
+ aprs_client = APRSDClient()
167
167
  aprs_client.consumer(rx_packet, raw=False)
168
168
  except aprslib.exceptions.ConnectionDrop:
169
169
  LOG.error('Connection dropped, reconnecting')
aprsd/cmds/server.py CHANGED
@@ -8,7 +8,7 @@ from oslo_config import cfg
8
8
  import aprsd
9
9
  from aprsd import cli_helper, plugin, threads, utils
10
10
  from aprsd import main as aprsd_main
11
- from aprsd.client import client_factory
11
+ from aprsd.client.client import APRSDClient
12
12
  from aprsd.main import cli
13
13
  from aprsd.packets import collector as packet_collector
14
14
  from aprsd.packets import seen_list
@@ -47,24 +47,18 @@ def server(ctx, flush):
47
47
  LOG.info(msg)
48
48
  LOG.info(f'APRSD Started version: {aprsd.__version__}')
49
49
 
50
- # Initialize the client factory and create
51
- # The correct client object ready for use
52
- if not client_factory.is_client_enabled():
53
- LOG.error('No Clients are enabled in config.')
54
- sys.exit(-1)
55
-
56
50
  # Make sure we have 1 client transport enabled
57
- if not client_factory.is_client_enabled():
51
+ if not APRSDClient().is_enabled:
58
52
  LOG.error('No Clients are enabled in config.')
59
53
  sys.exit(-1)
60
54
 
61
- if not client_factory.is_client_configured():
55
+ if not APRSDClient().is_configured:
62
56
  LOG.error('APRS client is not properly configured in config file.')
63
57
  sys.exit(-1)
64
58
 
65
59
  # Creates the client object
66
60
  LOG.info('Creating client connection')
67
- aprs_client = client_factory.create()
61
+ aprs_client = APRSDClient()
68
62
  LOG.info(aprs_client)
69
63
  if not aprs_client.login_success:
70
64
  # We failed to login, will just quit!
aprsd/log/log.py CHANGED
@@ -51,7 +51,7 @@ class InterceptHandler(logging.Handler):
51
51
  # Setup the log faciility
52
52
  # to disable log to stdout, but still log to file
53
53
  # use the --quiet option on the cmdln
54
- def setup_logging(loglevel=None, quiet=False):
54
+ def setup_logging(loglevel=None, quiet=False, custom_handler=None):
55
55
  if not loglevel:
56
56
  log_level = CONF.logging.log_level
57
57
  else:
@@ -85,7 +85,7 @@ def setup_logging(loglevel=None, quiet=False):
85
85
  logging.getLogger(name).propagate = name not in disable_list
86
86
 
87
87
  handlers = []
88
- if CONF.logging.enable_console_stdout:
88
+ if CONF.logging.enable_console_stdout and not quiet:
89
89
  handlers.append(
90
90
  {
91
91
  'sink': sys.stdout,
@@ -107,6 +107,9 @@ def setup_logging(loglevel=None, quiet=False):
107
107
  },
108
108
  )
109
109
 
110
+ if custom_handler:
111
+ handlers.append(custom_handler)
112
+
110
113
  # configure loguru
111
114
  logger.configure(handlers=handlers)
112
115
  logger.level('DEBUG', color='<fg #BABABA>')
aprsd/main.py CHANGED
@@ -23,7 +23,6 @@
23
23
  import datetime
24
24
  import importlib.metadata as imp
25
25
  import logging
26
- import signal
27
26
  import sys
28
27
  import time
29
28
  from importlib.metadata import version as metadata_version
@@ -41,7 +40,6 @@ from aprsd.stats import collector
41
40
  CONF = cfg.CONF
42
41
  LOG = logging.getLogger('APRSD')
43
42
  CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
44
- flask_enabled = False
45
43
 
46
44
 
47
45
  @click.group(cls=cli_helper.AliasedGroup, context_settings=CONTEXT_SETTINGS)
@@ -73,8 +71,6 @@ def main():
73
71
 
74
72
 
75
73
  def signal_handler(sig, frame):
76
- global flask_enabled
77
-
78
74
  click.echo('signal_handler: called')
79
75
  threads.APRSDThreadList().stop_all()
80
76
  if 'subprocess' not in str(frame):
@@ -96,9 +92,6 @@ def signal_handler(sig, frame):
96
92
  # signal.signal(signal.SIGTERM, sys.exit(0))
97
93
  # sys.exit(0)
98
94
 
99
- if flask_enabled:
100
- signal.signal(signal.SIGTERM, sys.exit(0))
101
-
102
95
 
103
96
  @cli.command()
104
97
  @cli_helper.add_options(cli_helper.common_options)