aprsd 1.0.0__py3-none-any.whl → 3.4.2__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.
Files changed (140) hide show
  1. aprsd/__init__.py +6 -4
  2. aprsd/cli_helper.py +151 -0
  3. aprsd/client/__init__.py +13 -0
  4. aprsd/client/aprsis.py +132 -0
  5. aprsd/client/base.py +105 -0
  6. aprsd/client/drivers/__init__.py +0 -0
  7. aprsd/client/drivers/aprsis.py +228 -0
  8. aprsd/client/drivers/fake.py +73 -0
  9. aprsd/client/drivers/kiss.py +119 -0
  10. aprsd/client/factory.py +88 -0
  11. aprsd/client/fake.py +48 -0
  12. aprsd/client/kiss.py +103 -0
  13. aprsd/client/stats.py +38 -0
  14. aprsd/cmds/__init__.py +0 -0
  15. aprsd/cmds/completion.py +22 -0
  16. aprsd/cmds/dev.py +162 -0
  17. aprsd/cmds/fetch_stats.py +156 -0
  18. aprsd/cmds/healthcheck.py +86 -0
  19. aprsd/cmds/list_plugins.py +319 -0
  20. aprsd/cmds/listen.py +231 -0
  21. aprsd/cmds/send_message.py +171 -0
  22. aprsd/cmds/server.py +137 -0
  23. aprsd/cmds/webchat.py +674 -0
  24. aprsd/conf/__init__.py +56 -0
  25. aprsd/conf/client.py +131 -0
  26. aprsd/conf/common.py +301 -0
  27. aprsd/conf/log.py +65 -0
  28. aprsd/conf/opts.py +80 -0
  29. aprsd/conf/plugin_common.py +182 -0
  30. aprsd/conf/plugin_email.py +105 -0
  31. aprsd/exception.py +13 -0
  32. aprsd/log/__init__.py +0 -0
  33. aprsd/log/log.py +138 -0
  34. aprsd/main.py +104 -867
  35. aprsd/packets/__init__.py +20 -0
  36. aprsd/packets/collector.py +79 -0
  37. aprsd/packets/core.py +823 -0
  38. aprsd/packets/log.py +161 -0
  39. aprsd/packets/packet_list.py +110 -0
  40. aprsd/packets/seen_list.py +49 -0
  41. aprsd/packets/tracker.py +103 -0
  42. aprsd/packets/watch_list.py +119 -0
  43. aprsd/plugin.py +474 -284
  44. aprsd/plugin_utils.py +86 -0
  45. aprsd/plugins/__init__.py +0 -0
  46. aprsd/plugins/email.py +709 -0
  47. aprsd/plugins/fortune.py +61 -0
  48. aprsd/plugins/location.py +179 -0
  49. aprsd/plugins/notify.py +61 -0
  50. aprsd/plugins/ping.py +31 -0
  51. aprsd/plugins/time.py +115 -0
  52. aprsd/plugins/version.py +31 -0
  53. aprsd/plugins/weather.py +405 -0
  54. aprsd/stats/__init__.py +20 -0
  55. aprsd/stats/app.py +49 -0
  56. aprsd/stats/collector.py +37 -0
  57. aprsd/threads/__init__.py +11 -0
  58. aprsd/threads/aprsd.py +119 -0
  59. aprsd/threads/keep_alive.py +131 -0
  60. aprsd/threads/log_monitor.py +121 -0
  61. aprsd/threads/registry.py +56 -0
  62. aprsd/threads/rx.py +354 -0
  63. aprsd/threads/stats.py +44 -0
  64. aprsd/threads/tx.py +255 -0
  65. aprsd/utils/__init__.py +218 -0
  66. aprsd/utils/counter.py +51 -0
  67. aprsd/utils/json.py +80 -0
  68. aprsd/utils/objectstore.py +123 -0
  69. aprsd/utils/ring_buffer.py +40 -0
  70. aprsd/utils/trace.py +180 -0
  71. aprsd/web/__init__.py +0 -0
  72. aprsd/web/admin/__init__.py +0 -0
  73. aprsd/web/admin/static/css/index.css +84 -0
  74. aprsd/web/admin/static/css/prism.css +4 -0
  75. aprsd/web/admin/static/css/tabs.css +35 -0
  76. aprsd/web/admin/static/images/Untitled.png +0 -0
  77. aprsd/web/admin/static/images/aprs-symbols-16-0.png +0 -0
  78. aprsd/web/admin/static/images/aprs-symbols-16-1.png +0 -0
  79. aprsd/web/admin/static/images/aprs-symbols-64-0.png +0 -0
  80. aprsd/web/admin/static/images/aprs-symbols-64-1.png +0 -0
  81. aprsd/web/admin/static/images/aprs-symbols-64-2.png +0 -0
  82. aprsd/web/admin/static/js/charts.js +235 -0
  83. aprsd/web/admin/static/js/echarts.js +465 -0
  84. aprsd/web/admin/static/js/logs.js +26 -0
  85. aprsd/web/admin/static/js/main.js +231 -0
  86. aprsd/web/admin/static/js/prism.js +12 -0
  87. aprsd/web/admin/static/js/send-message.js +114 -0
  88. aprsd/web/admin/static/js/tabs.js +28 -0
  89. aprsd/web/admin/templates/index.html +196 -0
  90. aprsd/web/chat/static/css/chat.css +115 -0
  91. aprsd/web/chat/static/css/index.css +66 -0
  92. aprsd/web/chat/static/css/style.css.map +1 -0
  93. aprsd/web/chat/static/css/tabs.css +41 -0
  94. aprsd/web/chat/static/css/upstream/bootstrap.min.css +6 -0
  95. aprsd/web/chat/static/css/upstream/font.woff2 +0 -0
  96. aprsd/web/chat/static/css/upstream/google-fonts.css +23 -0
  97. aprsd/web/chat/static/css/upstream/jquery-ui.css +1311 -0
  98. aprsd/web/chat/static/css/upstream/jquery.toast.css +28 -0
  99. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Bold.woff +0 -0
  100. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Bold.woff2 +0 -0
  101. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Regular.woff +0 -0
  102. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Regular.woff2 +0 -0
  103. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/icons.woff +0 -0
  104. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/icons.woff2 +0 -0
  105. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/outline-icons.woff +0 -0
  106. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/outline-icons.woff2 +0 -0
  107. aprsd/web/chat/static/images/Untitled.png +0 -0
  108. aprsd/web/chat/static/images/aprs-symbols-16-0.png +0 -0
  109. aprsd/web/chat/static/images/aprs-symbols-16-1.png +0 -0
  110. aprsd/web/chat/static/images/aprs-symbols-64-0.png +0 -0
  111. aprsd/web/chat/static/images/aprs-symbols-64-1.png +0 -0
  112. aprsd/web/chat/static/images/aprs-symbols-64-2.png +0 -0
  113. aprsd/web/chat/static/images/globe.svg +3 -0
  114. aprsd/web/chat/static/js/gps.js +84 -0
  115. aprsd/web/chat/static/js/main.js +45 -0
  116. aprsd/web/chat/static/js/send-message.js +585 -0
  117. aprsd/web/chat/static/js/tabs.js +28 -0
  118. aprsd/web/chat/static/js/upstream/bootstrap.bundle.min.js +7 -0
  119. aprsd/web/chat/static/js/upstream/jquery-3.7.1.min.js +2 -0
  120. aprsd/web/chat/static/js/upstream/jquery-ui.min.js +13 -0
  121. aprsd/web/chat/static/js/upstream/jquery.toast.js +374 -0
  122. aprsd/web/chat/static/js/upstream/semantic.min.js +11 -0
  123. aprsd/web/chat/static/js/upstream/socket.io.min.js +7 -0
  124. aprsd/web/chat/templates/index.html +139 -0
  125. aprsd/wsgi.py +315 -0
  126. aprsd-3.4.2.dist-info/AUTHORS +13 -0
  127. aprsd-3.4.2.dist-info/LICENSE +175 -0
  128. aprsd-3.4.2.dist-info/METADATA +793 -0
  129. aprsd-3.4.2.dist-info/RECORD +133 -0
  130. {aprsd-1.0.0.dist-info → aprsd-3.4.2.dist-info}/WHEEL +1 -1
  131. aprsd-3.4.2.dist-info/entry_points.txt +8 -0
  132. aprsd/fake_aprs.py +0 -83
  133. aprsd/utils.py +0 -166
  134. aprsd-1.0.0.dist-info/AUTHORS +0 -6
  135. aprsd-1.0.0.dist-info/METADATA +0 -181
  136. aprsd-1.0.0.dist-info/RECORD +0 -13
  137. aprsd-1.0.0.dist-info/entry_points.txt +0 -4
  138. aprsd-1.0.0.dist-info/pbr.json +0 -1
  139. /aprsd/{fuzzyclock.py → utils/fuzzyclock.py} +0 -0
  140. {aprsd-1.0.0.dist-info → aprsd-3.4.2.dist-info}/top_level.txt +0 -0
aprsd/plugin.py CHANGED
@@ -1,149 +1,189 @@
1
- # The base plugin class
1
+ from __future__ import annotations
2
+
2
3
  import abc
3
- import fnmatch
4
- import imp
4
+ import importlib
5
5
  import inspect
6
- import json
7
6
  import logging
8
- import os
9
7
  import re
10
- import subprocess
11
- import time
8
+ import textwrap
9
+ import threading
12
10
 
11
+ from oslo_config import cfg
13
12
  import pluggy
14
- import requests
15
- import six
16
13
 
17
- from aprsd.fuzzyclock import fuzzy
14
+ import aprsd
15
+ from aprsd import client, packets, threads
16
+ from aprsd.packets import watch_list
17
+
18
18
 
19
19
  # setup the global logger
20
+ CONF = cfg.CONF
20
21
  LOG = logging.getLogger("APRSD")
21
22
 
23
+ CORE_MESSAGE_PLUGINS = [
24
+ "aprsd.plugins.email.EmailPlugin",
25
+ "aprsd.plugins.fortune.FortunePlugin",
26
+ "aprsd.plugins.location.LocationPlugin",
27
+ "aprsd.plugins.ping.PingPlugin",
28
+ "aprsd.plugins.time.TimePlugin",
29
+ "aprsd.plugins.weather.USWeatherPlugin",
30
+ "aprsd.plugins.version.VersionPlugin",
31
+ ]
32
+
33
+ CORE_NOTIFY_PLUGINS = [
34
+ "aprsd.plugins.notify.NotifySeenPlugin",
35
+ ]
36
+
22
37
  hookspec = pluggy.HookspecMarker("aprsd")
23
38
  hookimpl = pluggy.HookimplMarker("aprsd")
24
39
 
25
- CORE_PLUGINS = [
26
- "FortunePlugin",
27
- "LocationPlugin",
28
- "PingPlugin",
29
- "TimePlugin",
30
- "WeatherPlugin",
31
- ]
32
40
 
41
+ class APRSDPluginSpec:
42
+ """A hook specification namespace."""
33
43
 
34
- def setup_plugins(config):
35
- """Create the plugin manager and register plugins."""
44
+ @hookspec
45
+ def filter(self, packet: type[packets.Packet]):
46
+ """My special little hook that you can customize."""
36
47
 
37
- LOG.info("Loading Core APRSD Command Plugins")
38
- enabled_plugins = config["aprsd"].get("enabled_plugins", None)
39
- pm = pluggy.PluginManager("aprsd")
40
- pm.add_hookspecs(APRSDCommandSpec)
41
- for p_name in CORE_PLUGINS:
42
- plugin_obj = None
43
- if enabled_plugins:
44
- if p_name in enabled_plugins:
45
- plugin_obj = globals()[p_name](config)
46
- else:
47
- # Enabled plugins isn't set, so we default to loading all of
48
- # the core plugins.
49
- plugin_obj = globals()[p_name](config)
50
48
 
51
- if plugin_obj:
52
- LOG.info(
53
- "Registering Command plugin '{}'({}) '{}'".format(
54
- p_name, plugin_obj.version, plugin_obj.command_regex
55
- )
56
- )
57
- pm.register(plugin_obj)
58
-
59
- plugin_dir = config["aprsd"].get("plugin_dir", None)
60
- if plugin_dir:
61
- LOG.info("Trying to load custom plugins from '{}'".format(plugin_dir))
62
- cpm = PluginManager(config)
63
- plugins_list = cpm.load_plugins(plugin_dir)
64
- LOG.info("Discovered {} modules to load".format(len(plugins_list)))
65
- for o in plugins_list:
66
- plugin_obj = None
67
- if enabled_plugins:
68
- if o["name"] in enabled_plugins:
69
- plugin_obj = o["obj"]
70
- else:
71
- LOG.info(
72
- "'{}' plugin not listed in config aprsd:enabled_plugins".format(
73
- o["name"]
49
+ class APRSDPluginBase(metaclass=abc.ABCMeta):
50
+ """The base class for all APRSD Plugins."""
51
+
52
+ config = None
53
+ rx_count = 0
54
+ tx_count = 0
55
+ version = aprsd.__version__
56
+
57
+ # Holds the list of APRSDThreads that the plugin creates
58
+ threads = []
59
+ # Set this in setup()
60
+ enabled = False
61
+
62
+ def __init__(self):
63
+ self.message_counter = 0
64
+ self.setup()
65
+ self.threads = self.create_threads() or []
66
+ self.start_threads()
67
+
68
+ def start_threads(self) -> None:
69
+ if self.enabled and self.threads:
70
+ if not isinstance(self.threads, list):
71
+ self.threads = [self.threads]
72
+
73
+ try:
74
+ for thread in self.threads:
75
+ if isinstance(thread, threads.APRSDThread):
76
+ thread.start()
77
+ else:
78
+ LOG.error(
79
+ "Can't start thread {}:{}, Must be a child "
80
+ "of aprsd.threads.APRSDThread".format(
81
+ self,
82
+ thread,
83
+ ),
74
84
  )
75
- )
76
- else:
77
- # not setting enabled plugins means load all?
78
- plugin_obj = o["obj"]
79
-
80
- if plugin_obj:
81
- LOG.info(
82
- "Registering Command plugin '{}'({}) '{}'".format(
83
- o["name"], o["obj"].version, o["obj"].command_regex
84
- )
85
+ except Exception:
86
+ LOG.error(
87
+ "Failed to start threads for plugin {}".format(
88
+ self,
89
+ ),
85
90
  )
86
- pm.register(o["obj"])
87
91
 
88
- else:
89
- LOG.info("Skipping Custom Plugins.")
92
+ @property
93
+ def message_count(self) -> int:
94
+ return self.message_counter
90
95
 
91
- LOG.info("Completed Plugin Loading.")
92
- return pm
96
+ def help(self) -> str:
97
+ return "Help!"
93
98
 
99
+ @abc.abstractmethod
100
+ def setup(self):
101
+ """Do any plugin setup here."""
102
+ self.enabled = True
94
103
 
95
- class PluginManager(object):
96
- def __init__(self, config):
97
- self.obj_list = []
98
- self.config = config
104
+ def create_threads(self):
105
+ """Gives the plugin writer the ability start a background thread."""
106
+ return []
99
107
 
100
- def load_plugins(self, module_path):
101
- dir_path = os.path.dirname(os.path.realpath(module_path))
102
- pattern = "*.py"
108
+ def rx_inc(self):
109
+ self.rx_count += 1
103
110
 
104
- self.obj_list = []
111
+ def tx_inc(self):
112
+ self.tx_count += 1
105
113
 
106
- for path, subdirs, files in os.walk(dir_path):
107
- for name in files:
108
- if fnmatch.fnmatch(name, pattern):
109
- found_module = imp.find_module(name[:-3], [path])
110
- module = imp.load_module(
111
- name, found_module[0], found_module[1], found_module[2]
112
- )
113
- for mem_name, obj in inspect.getmembers(module):
114
- if (
115
- inspect.isclass(obj)
116
- and inspect.getmodule(obj) is module
117
- and self.is_plugin(obj)
118
- ):
119
- self.obj_list.append(
120
- {"name": mem_name, "obj": obj(self.config)}
121
- )
122
-
123
- return self.obj_list
114
+ def stop_threads(self):
115
+ """Stop any threads this plugin might have created."""
116
+ for thread in self.threads:
117
+ if isinstance(thread, threads.APRSDThread):
118
+ thread.stop()
124
119
 
125
- def is_plugin(self, obj):
126
- for c in inspect.getmro(obj):
127
- if issubclass(c, APRSDPluginBase):
128
- return True
120
+ @abc.abstractmethod
121
+ def filter(self, packet: type[packets.Packet]) -> str | packets.MessagePacket:
122
+ pass
129
123
 
130
- return False
124
+ @abc.abstractmethod
125
+ def process(self, packet: type[packets.Packet]):
126
+ """This is called when the filter passes."""
127
+
128
+
129
+ class APRSDWatchListPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta):
130
+ """Base plugin class for all notification APRSD plugins.
131
+
132
+ All these plugins will get every packet seen by APRSD's
133
+ registered list of HAM callsigns in the config file's
134
+ watch_list.
135
+
136
+ When you want to 'notify' something when a packet is seen
137
+ by a particular HAM callsign, write a plugin based off of
138
+ this class.
139
+ """
140
+
141
+ def setup(self):
142
+ # if we have a watch list enabled, we need to add filtering
143
+ # to enable seeing packets from the watch list.
144
+ if CONF.watch_list.enabled:
145
+ # watch list is enabled
146
+ self.enabled = True
147
+ watch_list = CONF.watch_list.callsigns
148
+ # make sure the timeout is set or this doesn't work
149
+ if watch_list:
150
+ aprs_client = client.client_factory.create().client
151
+ filter_str = "b/{}".format("/".join(watch_list))
152
+ aprs_client.set_filter(filter_str)
153
+ else:
154
+ LOG.warning("Watch list enabled, but no callsigns set.")
131
155
 
156
+ @hookimpl
157
+ def filter(self, packet: type[packets.Packet]) -> str | packets.MessagePacket:
158
+ result = packets.NULL_MESSAGE
159
+ if self.enabled:
160
+ wl = watch_list.WatchList()
161
+ if wl.callsign_in_watchlist(packet.from_call):
162
+ # packet is from a callsign in the watch list
163
+ self.rx_inc()
164
+ try:
165
+ result = self.process(packet)
166
+ except Exception as ex:
167
+ LOG.error(
168
+ "Plugin {} failed to process packet {}".format(
169
+ self.__class__, ex,
170
+ ),
171
+ )
172
+ if result:
173
+ self.tx_inc()
174
+ else:
175
+ LOG.warning(f"{self.__class__} plugin is not enabled")
132
176
 
133
- class APRSDCommandSpec:
134
- """A hook specification namespace."""
177
+ return result
135
178
 
136
- @hookspec
137
- def run(self, fromcall, message, ack):
138
- """My special little hook that you can customize."""
139
- pass
140
179
 
180
+ class APRSDRegexCommandPluginBase(APRSDPluginBase, metaclass=abc.ABCMeta):
181
+ """Base Message plugin class.
141
182
 
142
- @six.add_metaclass(abc.ABCMeta)
143
- class APRSDPluginBase(object):
144
- def __init__(self, config):
145
- """The aprsd config object is stored."""
146
- self.config = config
183
+ When you want to search for a particular command in an
184
+ APRSD message and send a direct reply, write a plugin
185
+ based off of this class.
186
+ """
147
187
 
148
188
  @property
149
189
  def command_name(self):
@@ -155,196 +195,346 @@ class APRSDPluginBase(object):
155
195
  """The regex to match from the caller"""
156
196
  raise NotImplementedError
157
197
 
158
- @property
159
- def version(self):
160
- """Version"""
161
- raise NotImplementedError
162
-
163
- @hookimpl
164
- def run(self, fromcall, message, ack):
165
- if re.search(self.command_regex, message):
166
- return self.command(fromcall, message, ack)
198
+ def help(self):
199
+ return "{}: {}".format(
200
+ self.command_name.lower(),
201
+ self.command_regex,
202
+ )
167
203
 
168
- @abc.abstractmethod
169
- def command(self, fromcall, message, ack):
170
- """This is the command that runs when the regex matches.
204
+ def setup(self):
205
+ """Do any plugin setup here."""
206
+ self.enabled = True
171
207
 
172
- To reply with a message over the air, return a string
173
- to send.
174
- """
175
- pass
208
+ @hookimpl
209
+ def filter(self, packet: packets.MessagePacket) -> str | packets.MessagePacket:
210
+ LOG.debug(f"{self.__class__.__name__} called")
211
+ if not self.enabled:
212
+ result = f"{self.__class__.__name__} isn't enabled"
213
+ LOG.warning(result)
214
+ return result
215
+
216
+ if not isinstance(packet, packets.MessagePacket):
217
+ LOG.warning(f"{self.__class__.__name__} Got a {packet.__class__.__name__} ignoring")
218
+ return packets.NULL_MESSAGE
219
+
220
+ result = None
221
+
222
+ message = packet.message_text
223
+ tocall = packet.to_call
224
+
225
+ # Only process messages destined for us
226
+ # and is an APRS message format and has a message.
227
+ if (
228
+ tocall == CONF.callsign
229
+ and isinstance(packet, packets.MessagePacket)
230
+ and message
231
+ ):
232
+ if re.search(self.command_regex, message, re.IGNORECASE):
233
+ self.rx_inc()
234
+ try:
235
+ result = self.process(packet)
236
+ except Exception as ex:
237
+ LOG.error(
238
+ "Plugin {} failed to process packet {}".format(
239
+ self.__class__, ex,
240
+ ),
241
+ )
242
+ LOG.exception(ex)
243
+ if result:
244
+ self.tx_inc()
176
245
 
246
+ return result
177
247
 
178
- class FortunePlugin(APRSDPluginBase):
179
- """Fortune."""
180
248
 
181
- version = "1.0"
182
- command_regex = "^[fF]"
183
- command_name = "fortune"
249
+ class APRSFIKEYMixin:
250
+ """Mixin class to enable checking the existence of the aprs.fi apiKey."""
184
251
 
185
- def command(self, fromcall, message, ack):
186
- LOG.info("FortunePlugin")
187
- reply = None
188
- try:
189
- process = subprocess.Popen(
190
- ["/usr/games/fortune", "-s", "-n 60"], stdout=subprocess.PIPE
252
+ def ensure_aprs_fi_key(self):
253
+ if not CONF.aprs_fi.apiKey:
254
+ LOG.error("Config aprs_fi.apiKey is not set")
255
+ self.enabled = False
256
+ else:
257
+ self.enabled = True
258
+
259
+
260
+ class HelpPlugin(APRSDRegexCommandPluginBase):
261
+ """Help Plugin that is always enabled.
262
+
263
+ This plugin is in this file to prevent a circular import.
264
+ """
265
+
266
+ command_regex = "^[hH]"
267
+ command_name = "help"
268
+
269
+ def help(self):
270
+ return "Help: send APRS help or help <plugin>"
271
+
272
+ def process(self, packet: packets.MessagePacket):
273
+ LOG.info("HelpPlugin")
274
+ # fromcall = packet.get("from")
275
+ message = packet.message_text
276
+ # ack = packet.get("msgNo", "0")
277
+ a = re.search(r"^.*\s+(.*)", message)
278
+ command_name = None
279
+ if a is not None:
280
+ command_name = a.group(1).lower()
281
+
282
+ pm = PluginManager()
283
+
284
+ if command_name and "?" not in command_name:
285
+ # user wants help for a specific plugin
286
+ reply = None
287
+ for p in pm.get_plugins():
288
+ if (
289
+ p.enabled and isinstance(p, APRSDRegexCommandPluginBase)
290
+ and p.command_name.lower() == command_name
291
+ ):
292
+ reply = p.help()
293
+
294
+ if reply:
295
+ return reply
296
+
297
+ list = []
298
+ for p in pm.get_plugins():
299
+ LOG.debug(p)
300
+ if p.enabled and isinstance(p, APRSDRegexCommandPluginBase):
301
+ name = p.command_name.lower()
302
+ if name not in list and "help" not in name:
303
+ list.append(name)
304
+
305
+ list.sort()
306
+ reply = " ".join(list)
307
+ lines = textwrap.wrap(reply, 60)
308
+ replies = ["Send APRS MSG of 'help' or 'help <plugin>'"]
309
+ for line in lines:
310
+ replies.append(f"plugins: {line}")
311
+
312
+ for entry in replies:
313
+ LOG.debug(f"{len(entry)} {entry}")
314
+
315
+ LOG.debug(f"{replies}")
316
+ return replies
317
+
318
+
319
+ class PluginManager:
320
+ # The singleton instance object for this class
321
+ _instance = None
322
+
323
+ # the pluggy PluginManager for all Message plugins
324
+ _pluggy_pm = None
325
+ # the pluggy PluginManager for all WatchList plugins
326
+ _watchlist_pm = None
327
+
328
+ lock = None
329
+
330
+ def __new__(cls, *args, **kwargs):
331
+ """This magic turns this into a singleton."""
332
+ if cls._instance is None:
333
+ cls._instance = super().__new__(cls)
334
+ # Put any initialization here.
335
+ cls._instance.lock = threading.Lock()
336
+ cls._instance._init()
337
+ return cls._instance
338
+
339
+ def _init(self):
340
+ self._pluggy_pm = pluggy.PluginManager("aprsd")
341
+ self._pluggy_pm.add_hookspecs(APRSDPluginSpec)
342
+ # For the watchlist plugins
343
+ self._watchlist_pm = pluggy.PluginManager("aprsd")
344
+ self._watchlist_pm.add_hookspecs(APRSDPluginSpec)
345
+
346
+ def stats(self, serializable=False) -> dict:
347
+ """Collect and return stats for all plugins."""
348
+ def full_name_with_qualname(obj):
349
+ return "{}.{}".format(
350
+ obj.__class__.__module__,
351
+ obj.__class__.__qualname__,
191
352
  )
192
- reply = process.communicate()[0]
193
- # send_message(fromcall, reply.rstrip())
194
- reply = reply.decode(errors="ignore").rstrip()
195
- except Exception as ex:
196
- reply = "Fortune command failed '{}'".format(ex)
197
- LOG.error(reply)
198
353
 
199
- return reply
354
+ plugin_stats = {}
355
+ plugins = self.get_plugins()
356
+ if plugins:
200
357
 
358
+ for p in plugins:
359
+ plugin_stats[full_name_with_qualname(p)] = {
360
+ "enabled": p.enabled,
361
+ "rx": p.rx_count,
362
+ "tx": p.tx_count,
363
+ "version": p.version,
364
+ }
201
365
 
202
- class LocationPlugin(APRSDPluginBase):
203
- """Location!"""
366
+ return plugin_stats
204
367
 
205
- version = "1.0"
206
- command_regex = "^[lL]"
207
- command_name = "location"
368
+ def is_plugin(self, obj):
369
+ for c in inspect.getmro(obj):
370
+ if issubclass(c, APRSDPluginBase):
371
+ return True
372
+
373
+ return False
208
374
 
209
- def command(self, fromcall, message, ack):
210
- LOG.info("Location Plugin")
211
- # get last location of a callsign, get descriptive name from weather service
375
+ def _create_class(
376
+ self,
377
+ module_class_string,
378
+ super_cls: type = None,
379
+ **kwargs,
380
+ ):
381
+ """
382
+ Method to create a class from a fqn python string.
383
+ :param module_class_string: full name of the class to create an object
384
+ :param super_cls: expected super class for validity, None if bypass
385
+ :param kwargs: parameters to pass
386
+ :return:
387
+ """
388
+ module_name = None
389
+ class_name = None
212
390
  try:
213
- a = re.search(
214
- r"^.*\s+(.*)", message
215
- ) # optional second argument is a callsign to search
216
- if a is not None:
217
- searchcall = a.group(1)
218
- searchcall = searchcall.upper()
391
+ module_name, class_name = module_class_string.rsplit(".", 1)
392
+ module = importlib.import_module(module_name)
393
+ # Commented out because the email thread starts in a different context
394
+ # and hence gives a different singleton for the EmailStats
395
+ # module = importlib.reload(module)
396
+ except Exception as ex:
397
+ if not module_name:
398
+ LOG.error(f"Failed to load Plugin {module_class_string}")
219
399
  else:
220
- searchcall = (
221
- fromcall # if no second argument, search for calling station
222
- )
223
- url = (
224
- "http://api.aprs.fi/api/get?name="
225
- + searchcall
226
- + "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json"
227
- )
228
- response = requests.get(url)
229
- # aprs_data = json.loads(response.read())
230
- aprs_data = json.loads(response.text)
231
- lat = aprs_data["entries"][0]["lat"]
232
- lon = aprs_data["entries"][0]["lng"]
233
- try: # altitude not always provided
234
- alt = aprs_data["entries"][0]["altitude"]
235
- except Exception:
236
- alt = 0
237
- altfeet = int(alt * 3.28084)
238
- aprs_lasttime_seconds = aprs_data["entries"][0]["lasttime"]
239
- aprs_lasttime_seconds = aprs_lasttime_seconds.encode(
240
- "ascii", errors="ignore"
241
- ) # unicode to ascii
242
- delta_seconds = time.time() - int(aprs_lasttime_seconds)
243
- delta_hours = delta_seconds / 60 / 60
244
- url2 = (
245
- "https://forecast.weather.gov/MapClick.php?lat="
246
- + str(lat)
247
- + "&lon="
248
- + str(lon)
249
- + "&FcstType=json"
250
- )
251
- response2 = requests.get(url2)
252
- wx_data = json.loads(response2.text)
253
-
254
- reply = "{}: {} {}' {},{} {}h ago".format(
255
- searchcall,
256
- wx_data["location"]["areaDescription"],
257
- str(altfeet),
258
- str(alt),
259
- str(lon),
260
- str("%.1f" % round(delta_hours, 1)),
261
- ).rstrip()
262
- except Exception as e:
263
- LOG.debug("Locate failed with: " + "%s" % str(e))
264
- reply = "Unable to find station " + searchcall + ". Sending beacons?"
265
-
266
- return reply
267
-
268
-
269
- class PingPlugin(APRSDPluginBase):
270
- """Ping."""
271
-
272
- version = "1.0"
273
- command_regex = "^[pP]"
274
- command_name = "ping"
275
-
276
- def command(self, fromcall, message, ack):
277
- LOG.info("PINGPlugin")
278
- stm = time.localtime()
279
- h = stm.tm_hour
280
- m = stm.tm_min
281
- s = stm.tm_sec
282
- reply = (
283
- "Pong! " + str(h).zfill(2) + ":" + str(m).zfill(2) + ":" + str(s).zfill(2)
284
- )
285
- return reply.rstrip()
400
+ LOG.error(f"Failed to load Plugin '{module_name}' : '{ex}'")
401
+ return
286
402
 
287
-
288
- class TimePlugin(APRSDPluginBase):
289
- """Time command."""
290
-
291
- version = "1.0"
292
- command_regex = "^[tT]"
293
- command_name = "time"
294
-
295
- def command(self, fromcall, message, ack):
296
- LOG.info("TIME COMMAND")
297
- stm = time.localtime()
298
- h = stm.tm_hour
299
- m = stm.tm_min
300
- cur_time = fuzzy(h, m, 1)
301
- reply = "{} ({}:{} PDT) ({})".format(
302
- cur_time, str(h), str(m).rjust(2, "0"), message.rstrip()
403
+ assert hasattr(module, class_name), "class {} is not in {}".format(
404
+ class_name,
405
+ module_name,
303
406
  )
304
- return reply
407
+ # click.echo('reading class {} from module {}'.format(
408
+ # class_name, module_name))
409
+ cls = getattr(module, class_name)
410
+ if super_cls is not None:
411
+ assert issubclass(cls, super_cls), "class {} should inherit from {}".format(
412
+ class_name,
413
+ super_cls.__name__,
414
+ )
415
+ # click.echo('initialising {} with params {}'.format(class_name, kwargs))
416
+ obj = cls(**kwargs)
417
+ return obj
305
418
 
419
+ def _load_plugin(self, plugin_name):
420
+ """
421
+ Given a python fully qualified class path.name,
422
+ Try importing the path, then creating the object,
423
+ then registering it as a aprsd Command Plugin
424
+ """
425
+ plugin_obj = None
426
+ try:
427
+ plugin_obj = self._create_class(
428
+ plugin_name,
429
+ APRSDPluginBase,
430
+ )
431
+ if plugin_obj:
432
+ if isinstance(plugin_obj, APRSDWatchListPluginBase):
433
+ if plugin_obj.enabled:
434
+ LOG.info(
435
+ "Registering WatchList plugin '{}'({})".format(
436
+ plugin_name,
437
+ plugin_obj.version,
438
+ ),
439
+ )
440
+ self._watchlist_pm.register(plugin_obj)
441
+ else:
442
+ LOG.warning(f"Plugin {plugin_obj.__class__.__name__} is disabled")
443
+ elif isinstance(plugin_obj, APRSDRegexCommandPluginBase):
444
+ if plugin_obj.enabled:
445
+ LOG.info(
446
+ "Registering Regex plugin '{}'({}) -- {}".format(
447
+ plugin_name,
448
+ plugin_obj.version,
449
+ plugin_obj.command_regex,
450
+ ),
451
+ )
452
+ self._pluggy_pm.register(plugin_obj)
453
+ else:
454
+ LOG.warning(f"Plugin {plugin_obj.__class__.__name__} is disabled")
455
+ elif isinstance(plugin_obj, APRSDPluginBase):
456
+ if plugin_obj.enabled:
457
+ LOG.info(
458
+ "Registering Base plugin '{}'({})".format(
459
+ plugin_name,
460
+ plugin_obj.version,
461
+ ),
462
+ )
463
+ self._pluggy_pm.register(plugin_obj)
464
+ else:
465
+ LOG.warning(f"Plugin {plugin_obj.__class__.__name__} is disabled")
466
+ except Exception as ex:
467
+ LOG.error(f"Couldn't load plugin '{plugin_name}'")
468
+ LOG.exception(ex)
306
469
 
307
- class WeatherPlugin(APRSDPluginBase):
308
- """Weather Command"""
470
+ def reload_plugins(self):
471
+ with self.lock:
472
+ del self._pluggy_pm
473
+ self.setup_plugins()
309
474
 
310
- version = "1.0"
311
- command_regex = "^[wW]"
312
- command_name = "weather"
475
+ def setup_plugins(self, load_help_plugin=True):
476
+ """Create the plugin manager and register plugins."""
313
477
 
314
- def command(self, fromcall, message, ack):
315
- LOG.info("Weather Plugin")
316
- try:
317
- url = (
318
- "http://api.aprs.fi/api/get?"
319
- "&what=loc&apikey=104070.f9lE8qg34L8MZF&format=json"
320
- "&name=%s" % fromcall
321
- )
322
- response = requests.get(url)
323
- # aprs_data = json.loads(response.read())
324
- aprs_data = json.loads(response.text)
325
- lat = aprs_data["entries"][0]["lat"]
326
- lon = aprs_data["entries"][0]["lng"]
327
- url2 = (
328
- "https://forecast.weather.gov/MapClick.php?lat=%s"
329
- "&lon=%s&FcstType=json" % (lat, lon)
330
- )
331
- response2 = requests.get(url2)
332
- # wx_data = json.loads(response2.read())
333
- wx_data = json.loads(response2.text)
334
- reply = (
335
- "%sF(%sF/%sF) %s. %s, %s."
336
- % (
337
- wx_data["currentobservation"]["Temp"],
338
- wx_data["data"]["temperature"][0],
339
- wx_data["data"]["temperature"][1],
340
- wx_data["data"]["weather"][0],
341
- wx_data["time"]["startPeriodName"][1],
342
- wx_data["data"]["weather"][1],
343
- ).rstrip()
344
- )
345
- LOG.debug("reply: '{}' ".format(reply))
346
- except Exception as e:
347
- LOG.debug("Weather failed with: " + "%s" % str(e))
348
- reply = "Unable to find you (send beacon?)"
478
+ LOG.info("Loading APRSD Plugins")
479
+ # Help plugin is always enabled.
480
+ if load_help_plugin:
481
+ _help = HelpPlugin()
482
+ self._pluggy_pm.register(_help)
349
483
 
350
- return reply
484
+ enabled_plugins = CONF.enabled_plugins
485
+ if enabled_plugins:
486
+ for p_name in enabled_plugins:
487
+ self._load_plugin(p_name)
488
+ else:
489
+ # Enabled plugins isn't set, so we default to loading all of
490
+ # the core plugins.
491
+ for p_name in CORE_MESSAGE_PLUGINS:
492
+ self._load_plugin(p_name)
493
+
494
+ LOG.info("Completed Plugin Loading.")
495
+
496
+ def run(self, packet: packets.MessagePacket):
497
+ """Execute all the plugins run method."""
498
+ with self.lock:
499
+ return self._pluggy_pm.hook.filter(packet=packet)
500
+
501
+ def run_watchlist(self, packet: packets.Packet):
502
+ with self.lock:
503
+ return self._watchlist_pm.hook.filter(packet=packet)
504
+
505
+ def stop(self):
506
+ """Stop all threads created by all plugins."""
507
+ with self.lock:
508
+ for p in self.get_plugins():
509
+ if hasattr(p, "stop_threads"):
510
+ p.stop_threads()
511
+
512
+ def register_msg(self, obj):
513
+ """Register the plugin."""
514
+ with self.lock:
515
+ self._pluggy_pm.register(obj)
516
+
517
+ def get_plugins(self):
518
+ plugin_list = []
519
+ if self._pluggy_pm:
520
+ for plug in self._pluggy_pm.get_plugins():
521
+ plugin_list.append(plug)
522
+ if self._watchlist_pm:
523
+ for plug in self._watchlist_pm.get_plugins():
524
+ plugin_list.append(plug)
525
+
526
+ return plugin_list
527
+
528
+ def get_watchlist_plugins(self):
529
+ pl = []
530
+ if self._watchlist_pm:
531
+ for plug in self._watchlist_pm.get_plugins():
532
+ pl.append(plug)
533
+ return pl
534
+
535
+ def get_message_plugins(self):
536
+ pl = []
537
+ if self._pluggy_pm:
538
+ for plug in self._pluggy_pm.get_plugins():
539
+ pl.append(plug)
540
+ return pl