aprsd 1.0.0__py3-none-any.whl → 3.4.1__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 (141) 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 +224 -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 +230 -0
  21. aprsd/cmds/send_message.py +174 -0
  22. aprsd/cmds/server.py +142 -0
  23. aprsd/cmds/webchat.py +681 -0
  24. aprsd/conf/__init__.py +56 -0
  25. aprsd/conf/client.py +131 -0
  26. aprsd/conf/common.py +302 -0
  27. aprsd/conf/log.py +65 -0
  28. aprsd/conf/opts.py +80 -0
  29. aprsd/conf/plugin_common.py +191 -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/messaging.py +4 -0
  36. aprsd/packets/__init__.py +12 -0
  37. aprsd/packets/collector.py +56 -0
  38. aprsd/packets/core.py +823 -0
  39. aprsd/packets/log.py +143 -0
  40. aprsd/packets/packet_list.py +116 -0
  41. aprsd/packets/seen_list.py +54 -0
  42. aprsd/packets/tracker.py +109 -0
  43. aprsd/packets/watch_list.py +122 -0
  44. aprsd/plugin.py +475 -284
  45. aprsd/plugin_utils.py +86 -0
  46. aprsd/plugins/__init__.py +0 -0
  47. aprsd/plugins/email.py +709 -0
  48. aprsd/plugins/fortune.py +61 -0
  49. aprsd/plugins/location.py +179 -0
  50. aprsd/plugins/notify.py +61 -0
  51. aprsd/plugins/ping.py +31 -0
  52. aprsd/plugins/time.py +115 -0
  53. aprsd/plugins/version.py +31 -0
  54. aprsd/plugins/weather.py +405 -0
  55. aprsd/stats/__init__.py +20 -0
  56. aprsd/stats/app.py +49 -0
  57. aprsd/stats/collector.py +38 -0
  58. aprsd/threads/__init__.py +11 -0
  59. aprsd/threads/aprsd.py +119 -0
  60. aprsd/threads/keep_alive.py +124 -0
  61. aprsd/threads/log_monitor.py +121 -0
  62. aprsd/threads/registry.py +56 -0
  63. aprsd/threads/rx.py +354 -0
  64. aprsd/threads/stats.py +44 -0
  65. aprsd/threads/tx.py +255 -0
  66. aprsd/utils/__init__.py +163 -0
  67. aprsd/utils/counter.py +51 -0
  68. aprsd/utils/json.py +80 -0
  69. aprsd/utils/objectstore.py +123 -0
  70. aprsd/utils/ring_buffer.py +40 -0
  71. aprsd/utils/trace.py +180 -0
  72. aprsd/web/__init__.py +0 -0
  73. aprsd/web/admin/__init__.py +0 -0
  74. aprsd/web/admin/static/css/index.css +84 -0
  75. aprsd/web/admin/static/css/prism.css +4 -0
  76. aprsd/web/admin/static/css/tabs.css +35 -0
  77. aprsd/web/admin/static/images/Untitled.png +0 -0
  78. aprsd/web/admin/static/images/aprs-symbols-16-0.png +0 -0
  79. aprsd/web/admin/static/images/aprs-symbols-16-1.png +0 -0
  80. aprsd/web/admin/static/images/aprs-symbols-64-0.png +0 -0
  81. aprsd/web/admin/static/images/aprs-symbols-64-1.png +0 -0
  82. aprsd/web/admin/static/images/aprs-symbols-64-2.png +0 -0
  83. aprsd/web/admin/static/js/charts.js +235 -0
  84. aprsd/web/admin/static/js/echarts.js +465 -0
  85. aprsd/web/admin/static/js/logs.js +26 -0
  86. aprsd/web/admin/static/js/main.js +231 -0
  87. aprsd/web/admin/static/js/prism.js +12 -0
  88. aprsd/web/admin/static/js/send-message.js +114 -0
  89. aprsd/web/admin/static/js/tabs.js +28 -0
  90. aprsd/web/admin/templates/index.html +196 -0
  91. aprsd/web/chat/static/css/chat.css +115 -0
  92. aprsd/web/chat/static/css/index.css +66 -0
  93. aprsd/web/chat/static/css/style.css.map +1 -0
  94. aprsd/web/chat/static/css/tabs.css +41 -0
  95. aprsd/web/chat/static/css/upstream/bootstrap.min.css +6 -0
  96. aprsd/web/chat/static/css/upstream/font.woff2 +0 -0
  97. aprsd/web/chat/static/css/upstream/google-fonts.css +23 -0
  98. aprsd/web/chat/static/css/upstream/jquery-ui.css +1311 -0
  99. aprsd/web/chat/static/css/upstream/jquery.toast.css +28 -0
  100. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Bold.woff +0 -0
  101. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Bold.woff2 +0 -0
  102. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Regular.woff +0 -0
  103. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/LatoLatin-Regular.woff2 +0 -0
  104. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/icons.woff +0 -0
  105. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/icons.woff2 +0 -0
  106. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/outline-icons.woff +0 -0
  107. aprsd/web/chat/static/css/upstream/themes/default/assets/fonts/outline-icons.woff2 +0 -0
  108. aprsd/web/chat/static/images/Untitled.png +0 -0
  109. aprsd/web/chat/static/images/aprs-symbols-16-0.png +0 -0
  110. aprsd/web/chat/static/images/aprs-symbols-16-1.png +0 -0
  111. aprsd/web/chat/static/images/aprs-symbols-64-0.png +0 -0
  112. aprsd/web/chat/static/images/aprs-symbols-64-1.png +0 -0
  113. aprsd/web/chat/static/images/aprs-symbols-64-2.png +0 -0
  114. aprsd/web/chat/static/images/globe.svg +3 -0
  115. aprsd/web/chat/static/js/gps.js +84 -0
  116. aprsd/web/chat/static/js/main.js +45 -0
  117. aprsd/web/chat/static/js/send-message.js +585 -0
  118. aprsd/web/chat/static/js/tabs.js +28 -0
  119. aprsd/web/chat/static/js/upstream/bootstrap.bundle.min.js +7 -0
  120. aprsd/web/chat/static/js/upstream/jquery-3.7.1.min.js +2 -0
  121. aprsd/web/chat/static/js/upstream/jquery-ui.min.js +13 -0
  122. aprsd/web/chat/static/js/upstream/jquery.toast.js +374 -0
  123. aprsd/web/chat/static/js/upstream/semantic.min.js +11 -0
  124. aprsd/web/chat/static/js/upstream/socket.io.min.js +7 -0
  125. aprsd/web/chat/templates/index.html +139 -0
  126. aprsd/wsgi.py +315 -0
  127. aprsd-3.4.1.dist-info/AUTHORS +13 -0
  128. aprsd-3.4.1.dist-info/LICENSE +175 -0
  129. aprsd-3.4.1.dist-info/METADATA +799 -0
  130. aprsd-3.4.1.dist-info/RECORD +134 -0
  131. {aprsd-1.0.0.dist-info → aprsd-3.4.1.dist-info}/WHEEL +1 -1
  132. aprsd-3.4.1.dist-info/entry_points.txt +8 -0
  133. aprsd/fake_aprs.py +0 -83
  134. aprsd/utils.py +0 -166
  135. aprsd-1.0.0.dist-info/AUTHORS +0 -6
  136. aprsd-1.0.0.dist-info/METADATA +0 -181
  137. aprsd-1.0.0.dist-info/RECORD +0 -13
  138. aprsd-1.0.0.dist-info/entry_points.txt +0 -4
  139. aprsd-1.0.0.dist-info/pbr.json +0 -1
  140. /aprsd/{fuzzyclock.py → utils/fuzzyclock.py} +0 -0
  141. {aprsd-1.0.0.dist-info → aprsd-3.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,405 @@
1
+ import json
2
+ import logging
3
+ import re
4
+
5
+ from oslo_config import cfg
6
+ import requests
7
+
8
+ from aprsd import plugin, plugin_utils
9
+ from aprsd.utils import trace
10
+
11
+
12
+ CONF = cfg.CONF
13
+ LOG = logging.getLogger("APRSD")
14
+
15
+
16
+ class USWeatherPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
17
+ """USWeather Command
18
+
19
+ Returns a weather report for the calling weather station
20
+ inside the United States only. This uses the
21
+ forecast.weather.gov API to fetch the weather.
22
+
23
+ This service does not require an apiKey.
24
+
25
+ How to Call: Send a message to aprsd
26
+ "weather" - returns weather near the calling callsign
27
+ """
28
+
29
+ # command_regex = r"^([w][x]|[w][x]\s|weather)"
30
+ command_regex = r"^[wW]"
31
+
32
+ command_name = "USWeather"
33
+ short_description = "Provide USA only weather of GPS Beacon location"
34
+
35
+ def setup(self):
36
+ self.ensure_aprs_fi_key()
37
+
38
+ @trace.trace
39
+ def process(self, packet):
40
+ LOG.info("Weather Plugin")
41
+ fromcall = packet.from_call
42
+ message = packet.get("message_text", None)
43
+ # message = packet.get("message_text", None)
44
+ # ack = packet.get("msgNo", "0")
45
+ a = re.search(r"^.*\s+(.*)", message)
46
+ if a is not None:
47
+ searchcall = a.group(1)
48
+ searchcall = searchcall.upper()
49
+ else:
50
+ searchcall = fromcall
51
+ api_key = CONF.aprs_fi.apiKey
52
+ try:
53
+ aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
54
+ except Exception as ex:
55
+ LOG.error(f"Failed to fetch aprs.fi data {ex}")
56
+ return "Failed to fetch aprs.fi location"
57
+
58
+ LOG.debug(f"LocationPlugin: aprs_data = {aprs_data}")
59
+ if not len(aprs_data["entries"]):
60
+ LOG.error("Didn't get any entries from aprs.fi")
61
+ return "Failed to fetch aprs.fi location"
62
+
63
+ lat = aprs_data["entries"][0]["lat"]
64
+ lon = aprs_data["entries"][0]["lng"]
65
+
66
+ try:
67
+ wx_data = plugin_utils.get_weather_gov_for_gps(lat, lon)
68
+ except Exception as ex:
69
+ LOG.error(f"Couldn't fetch forecast.weather.gov '{ex}'")
70
+ return "Unable to get weather"
71
+
72
+ LOG.info(f"WX data {wx_data}")
73
+
74
+ reply = (
75
+ "%sF(%sF/%sF) %s. %s, %s."
76
+ % (
77
+ wx_data["currentobservation"]["Temp"],
78
+ wx_data["data"]["temperature"][0],
79
+ wx_data["data"]["temperature"][1],
80
+ wx_data["data"]["weather"][0],
81
+ wx_data["time"]["startPeriodName"][1],
82
+ wx_data["data"]["weather"][1],
83
+ )
84
+ ).rstrip()
85
+ LOG.debug(f"reply: '{reply}' ")
86
+ return reply
87
+
88
+
89
+ class USMetarPlugin(plugin.APRSDRegexCommandPluginBase, plugin.APRSFIKEYMixin):
90
+ """METAR Command
91
+
92
+ This provides a METAR weather report from a station near the caller
93
+ or callsign using the forecast.weather.gov api. This only works
94
+ for stations inside the United States.
95
+
96
+ This service does not require an apiKey.
97
+
98
+ How to Call: Send a message to aprsd
99
+ "metar" - returns metar report near the calling callsign
100
+ "metar CALLSIGN" - returns metar report near CALLSIGN
101
+
102
+ """
103
+
104
+ command_regex = r"^([m]|[M]|[m]\s|metar)"
105
+ command_name = "USMetar"
106
+ short_description = "USA only METAR of GPS Beacon location"
107
+
108
+ def setup(self):
109
+ self.ensure_aprs_fi_key()
110
+
111
+ @trace.trace
112
+ def process(self, packet):
113
+ fromcall = packet.get("from")
114
+ message = packet.get("message_text", None)
115
+ # ack = packet.get("msgNo", "0")
116
+ LOG.info(f"WX Plugin '{message}'")
117
+ a = re.search(r"^.*\s+(.*)", message)
118
+ if a is not None:
119
+ searchcall = a.group(1)
120
+ station = searchcall.upper()
121
+ try:
122
+ resp = plugin_utils.get_weather_gov_metar(station)
123
+ except Exception as e:
124
+ LOG.debug(f"Weather failed with: {str(e)}")
125
+ reply = "Unable to find station METAR"
126
+ else:
127
+ station_data = json.loads(resp.text)
128
+ reply = station_data["properties"]["rawMessage"]
129
+
130
+ return reply
131
+ else:
132
+ # if no second argument, search for calling station
133
+ fromcall = fromcall
134
+
135
+ api_key = CONF.aprs_fi.apiKey
136
+
137
+ try:
138
+ aprs_data = plugin_utils.get_aprs_fi(api_key, fromcall)
139
+ except Exception as ex:
140
+ LOG.error(f"Failed to fetch aprs.fi data {ex}")
141
+ return "Failed to fetch aprs.fi location"
142
+
143
+ # LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
144
+ if not len(aprs_data["entries"]):
145
+ LOG.error("Found no entries from aprs.fi!")
146
+ return "Failed to fetch aprs.fi location"
147
+
148
+ lat = aprs_data["entries"][0]["lat"]
149
+ lon = aprs_data["entries"][0]["lng"]
150
+
151
+ try:
152
+ wx_data = plugin_utils.get_weather_gov_for_gps(lat, lon)
153
+ except Exception as ex:
154
+ LOG.error(f"Couldn't fetch forecast.weather.gov '{ex}'")
155
+ return "Unable to metar find station."
156
+
157
+ if wx_data["location"]["metar"]:
158
+ station = wx_data["location"]["metar"]
159
+ try:
160
+ resp = plugin_utils.get_weather_gov_metar(station)
161
+ except Exception as e:
162
+ LOG.debug(f"Weather failed with: {str(e)}")
163
+ reply = "Failed to get Metar"
164
+ else:
165
+ station_data = json.loads(resp.text)
166
+ reply = station_data["properties"]["rawMessage"]
167
+ else:
168
+ # Couldn't find a station
169
+ reply = "No Metar station found"
170
+
171
+ return reply
172
+
173
+
174
+ class OWMWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
175
+ """OpenWeatherMap Weather Command
176
+
177
+ This provides weather near the caller or callsign.
178
+
179
+ How to Call: Send a message to aprsd
180
+ "weather" - returns the weather near the calling callsign
181
+ "weather CALLSIGN" - returns the weather near CALLSIGN
182
+
183
+ This plugin uses the openweathermap API to fetch
184
+ location and weather information.
185
+
186
+ To use this plugin you need to get an openweathermap
187
+ account and apikey.
188
+
189
+ https://home.openweathermap.org/api_keys
190
+
191
+ """
192
+
193
+ # command_regex = r"^([w][x]|[w][x]\s|weather)"
194
+ command_regex = r"^[wW]"
195
+
196
+ command_name = "OpenWeatherMap"
197
+ short_description = "OpenWeatherMap weather of GPS Beacon location"
198
+
199
+ def setup(self):
200
+ if not CONF.owm_weather_plugin.apiKey:
201
+ LOG.error("Config.owm_weather_plugin.apiKey is not set. Disabling")
202
+ self.enabled = False
203
+ else:
204
+ self.enabled = True
205
+
206
+ def help(self):
207
+ _help = [
208
+ "openweathermap: Send {} to get weather "
209
+ "from your location".format(self.command_regex),
210
+ "openweathermap: Send {} <callsign> to get "
211
+ "weather from <callsign>".format(self.command_regex),
212
+ ]
213
+ return _help
214
+
215
+ @trace.trace
216
+ def process(self, packet):
217
+ fromcall = packet.get("from_call")
218
+ message = packet.get("message_text", None)
219
+ # ack = packet.get("msgNo", "0")
220
+ LOG.info(f"OWMWeather Plugin '{message}'")
221
+ a = re.search(r"^.*\s+(.*)", message)
222
+ if a is not None:
223
+ searchcall = a.group(1)
224
+ searchcall = searchcall.upper()
225
+ else:
226
+ searchcall = fromcall
227
+
228
+ api_key = CONF.aprs_fi.apiKey
229
+
230
+ try:
231
+ aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
232
+ except Exception as ex:
233
+ LOG.error(f"Failed to fetch aprs.fi data {ex}")
234
+ return "Failed to fetch location"
235
+
236
+ # LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
237
+ if not len(aprs_data["entries"]):
238
+ LOG.error("Found no entries from aprs.fi!")
239
+ return "Failed to fetch location"
240
+
241
+ lat = aprs_data["entries"][0]["lat"]
242
+ lon = aprs_data["entries"][0]["lng"]
243
+
244
+ units = CONF.units
245
+ api_key = CONF.owm_weather_plugin.apiKey
246
+ try:
247
+ wx_data = plugin_utils.fetch_openweathermap(
248
+ api_key,
249
+ lat,
250
+ lon,
251
+ units=units,
252
+ exclude="minutely,hourly",
253
+ )
254
+ except Exception as ex:
255
+ LOG.error(f"Couldn't fetch openweathermap api '{ex}'")
256
+ # default to UTC
257
+ return "Unable to get weather"
258
+
259
+ if units == "metric":
260
+ degree = "C"
261
+ else:
262
+ degree = "F"
263
+
264
+ if "wind_gust" in wx_data["current"]:
265
+ wind = "{:.0f}@{}G{:.0f}".format(
266
+ wx_data["current"]["wind_speed"],
267
+ wx_data["current"]["wind_deg"],
268
+ wx_data["current"]["wind_gust"],
269
+ )
270
+ else:
271
+ wind = "{:.0f}@{}".format(
272
+ wx_data["current"]["wind_speed"],
273
+ wx_data["current"]["wind_deg"],
274
+ )
275
+
276
+ # LOG.debug(wx_data["current"])
277
+ # LOG.debug(wx_data["daily"])
278
+ reply = "{} {:.1f}{}/{:.1f}{} Wind {} {}%".format(
279
+ wx_data["current"]["weather"][0]["description"],
280
+ wx_data["current"]["temp"],
281
+ degree,
282
+ wx_data["current"]["dew_point"],
283
+ degree,
284
+ wind,
285
+ wx_data["current"]["humidity"],
286
+ )
287
+
288
+ return reply
289
+
290
+
291
+ class AVWXWeatherPlugin(plugin.APRSDRegexCommandPluginBase):
292
+ """AVWXWeatherMap Weather Command
293
+
294
+ Fetches a METAR weather report for the nearest
295
+ weather station from the callsign
296
+ Can be called with:
297
+ metar - fetches metar for caller
298
+ metar <CALLSIGN> - fetches metar for <CALLSIGN>
299
+
300
+ This plugin requires the avwx-api service
301
+ to provide the metar for a station near
302
+ the callsign.
303
+
304
+ avwx-api is an opensource project that has
305
+ a hosted service here: https://avwx.rest/
306
+
307
+ You can launch your own avwx-api in a container
308
+ by cloning the githug repo here: https://github.com/avwx-rest/AVWX-API
309
+
310
+ Then build the docker container with:
311
+ docker build -f Dockerfile -t avwx-api:master .
312
+ """
313
+
314
+ command_regex = r"^([m]|[m]|[m]\s|metar)"
315
+ command_name = "AVWXWeather"
316
+ short_description = "AVWX weather of GPS Beacon location"
317
+
318
+ def setup(self):
319
+ if not CONF.avwx_plugin.base_url:
320
+ LOG.error("Config avwx_plugin.base_url not specified. Disabling")
321
+ return False
322
+ elif not CONF.avwx_plugin.apiKey:
323
+ LOG.error("Config avwx_plugin.apiKey not specified. Disabling")
324
+ return False
325
+ else:
326
+ return True
327
+
328
+ def help(self):
329
+ _help = [
330
+ "avwxweather: Send {} to get weather "
331
+ "from your location".format(self.command_regex),
332
+ "avwxweather: Send {} <callsign> to get "
333
+ "weather from <callsign>".format(self.command_regex),
334
+ ]
335
+ return _help
336
+
337
+ @trace.trace
338
+ def process(self, packet):
339
+ fromcall = packet.get("from")
340
+ message = packet.get("message_text", None)
341
+ # ack = packet.get("msgNo", "0")
342
+ LOG.info(f"AVWXWeather Plugin '{message}'")
343
+ a = re.search(r"^.*\s+(.*)", message)
344
+ if a is not None:
345
+ searchcall = a.group(1)
346
+ searchcall = searchcall.upper()
347
+ else:
348
+ searchcall = fromcall
349
+
350
+ api_key = CONF.aprs_fi.apiKey
351
+ try:
352
+ aprs_data = plugin_utils.get_aprs_fi(api_key, searchcall)
353
+ except Exception as ex:
354
+ LOG.error(f"Failed to fetch aprs.fi data {ex}")
355
+ return "Failed to fetch location"
356
+
357
+ # LOG.debug("LocationPlugin: aprs_data = {}".format(aprs_data))
358
+ if not len(aprs_data["entries"]):
359
+ LOG.error("Found no entries from aprs.fi!")
360
+ return "Failed to fetch location"
361
+
362
+ lat = aprs_data["entries"][0]["lat"]
363
+ lon = aprs_data["entries"][0]["lng"]
364
+
365
+ api_key = CONF.avwx_plugin.apiKey
366
+ base_url = CONF.avwx_plugin.base_url
367
+ token = f"TOKEN {api_key}"
368
+ headers = {"Authorization": token}
369
+ try:
370
+ coord = f"{lat},{lon}"
371
+ url = (
372
+ "{}/api/station/near/{}?"
373
+ "n=1&airport=false&reporting=true&format=json".format(base_url, coord)
374
+ )
375
+
376
+ LOG.debug(f"Get stations near me '{url}'")
377
+ response = requests.get(url, headers=headers)
378
+ except Exception as ex:
379
+ LOG.error(ex)
380
+ raise Exception(f"Failed to get the weather '{ex}'")
381
+ else:
382
+ wx_data = json.loads(response.text)
383
+
384
+ # LOG.debug(wx_data)
385
+ station = wx_data[0]["station"]["icao"]
386
+
387
+ try:
388
+ url = (
389
+ "{}/api/metar/{}?options=info,translate,summary"
390
+ "&airport=true&reporting=true&format=json&onfail=cache".format(
391
+ base_url,
392
+ station,
393
+ )
394
+ )
395
+
396
+ LOG.debug(f"Get METAR '{url}'")
397
+ response = requests.get(url, headers=headers)
398
+ except Exception as ex:
399
+ LOG.error(ex)
400
+ raise Exception(f"Failed to get metar {ex}")
401
+ else:
402
+ metar_data = json.loads(response.text)
403
+
404
+ # LOG.debug(metar_data)
405
+ return metar_data["raw"]
@@ -0,0 +1,20 @@
1
+ from aprsd import plugin
2
+ from aprsd.client import stats as client_stats
3
+ from aprsd.packets import packet_list, seen_list, tracker, watch_list
4
+ from aprsd.plugins import email
5
+ from aprsd.stats import app, collector
6
+ from aprsd.threads import aprsd
7
+
8
+
9
+ # Create the collector and register all the objects
10
+ # that APRSD has that implement the stats protocol
11
+ stats_collector = collector.Collector()
12
+ stats_collector.register_producer(app.APRSDStats)
13
+ stats_collector.register_producer(packet_list.PacketList)
14
+ stats_collector.register_producer(watch_list.WatchList)
15
+ stats_collector.register_producer(tracker.PacketTrack)
16
+ stats_collector.register_producer(plugin.PluginManager)
17
+ stats_collector.register_producer(aprsd.APRSDThreadList)
18
+ stats_collector.register_producer(email.EmailStats)
19
+ stats_collector.register_producer(client_stats.APRSClientStats)
20
+ stats_collector.register_producer(seen_list.SeenList)
aprsd/stats/app.py ADDED
@@ -0,0 +1,49 @@
1
+ import datetime
2
+ import tracemalloc
3
+
4
+ from oslo_config import cfg
5
+
6
+ import aprsd
7
+ from aprsd import utils
8
+ from aprsd.log import log as aprsd_log
9
+
10
+
11
+ CONF = cfg.CONF
12
+
13
+
14
+ class APRSDStats:
15
+ """The AppStats class is used to collect stats from the application."""
16
+
17
+ _instance = None
18
+ start_time = None
19
+
20
+ def __new__(cls, *args, **kwargs):
21
+ """Have to override the new method to make this a singleton
22
+
23
+ instead of using @singletone decorator so the unit tests work.
24
+ """
25
+ if not cls._instance:
26
+ cls._instance = super().__new__(cls)
27
+ cls._instance.start_time = datetime.datetime.now()
28
+ return cls._instance
29
+
30
+ def uptime(self):
31
+ return datetime.datetime.now() - self.start_time
32
+
33
+ def stats(self, serializable=False) -> dict:
34
+ current, peak = tracemalloc.get_traced_memory()
35
+ uptime = self.uptime()
36
+ qsize = aprsd_log.logging_queue.qsize()
37
+ if serializable:
38
+ uptime = str(uptime)
39
+ stats = {
40
+ "version": aprsd.__version__,
41
+ "uptime": uptime,
42
+ "callsign": CONF.callsign,
43
+ "memory_current": int(current),
44
+ "memory_current_str": utils.human_size(current),
45
+ "memory_peak": int(peak),
46
+ "memory_peak_str": utils.human_size(peak),
47
+ "loging_queue": qsize,
48
+ }
49
+ return stats
@@ -0,0 +1,38 @@
1
+ import logging
2
+ from typing import Callable, Protocol, runtime_checkable
3
+
4
+ from aprsd.utils import singleton
5
+
6
+
7
+ LOG = logging.getLogger("APRSD")
8
+
9
+
10
+ @runtime_checkable
11
+ class StatsProducer(Protocol):
12
+ """The StatsProducer protocol is used to define the interface for collecting stats."""
13
+ def stats(self, serializeable=False) -> dict:
14
+ """provide stats in a dictionary format."""
15
+ ...
16
+
17
+
18
+ @singleton
19
+ class Collector:
20
+ """The Collector class is used to collect stats from multiple StatsProducer instances."""
21
+ def __init__(self):
22
+ self.producers: list[Callable] = []
23
+
24
+ def collect(self, serializable=False) -> dict:
25
+ stats = {}
26
+ for name in self.producers:
27
+ cls = name()
28
+ if isinstance(cls, StatsProducer):
29
+ try:
30
+ stats[cls.__class__.__name__] = cls.stats(serializable=serializable).copy()
31
+ except Exception as e:
32
+ LOG.error(f"Error in producer {name} (stats): {e}")
33
+ else:
34
+ raise TypeError(f"{cls} is not an instance of StatsProducer")
35
+ return stats
36
+
37
+ def register_producer(self, producer_name: Callable):
38
+ self.producers.append(producer_name)
@@ -0,0 +1,11 @@
1
+ import queue
2
+
3
+ # Make these available to anyone importing
4
+ # aprsd.threads
5
+ from .aprsd import APRSDThread, APRSDThreadList # noqa: F401
6
+ from .rx import ( # noqa: F401
7
+ APRSDDupeRXThread, APRSDProcessPacketThread, APRSDRXThread,
8
+ )
9
+
10
+
11
+ packet_queue = queue.Queue(maxsize=20)
aprsd/threads/aprsd.py ADDED
@@ -0,0 +1,119 @@
1
+ import abc
2
+ import datetime
3
+ import logging
4
+ import threading
5
+ from typing import List
6
+
7
+ import wrapt
8
+
9
+
10
+ LOG = logging.getLogger("APRSD")
11
+
12
+
13
+ class APRSDThread(threading.Thread, metaclass=abc.ABCMeta):
14
+ """Base class for all threads in APRSD."""
15
+
16
+ loop_count = 1
17
+
18
+ def __init__(self, name):
19
+ super().__init__(name=name)
20
+ self.thread_stop = False
21
+ APRSDThreadList().add(self)
22
+ self._last_loop = datetime.datetime.now()
23
+
24
+ def _should_quit(self):
25
+ """ see if we have a quit message from the global queue."""
26
+ if self.thread_stop:
27
+ return True
28
+
29
+ def stop(self):
30
+ self.thread_stop = True
31
+
32
+ @abc.abstractmethod
33
+ def loop(self):
34
+ pass
35
+
36
+ def _cleanup(self):
37
+ """Add code to subclass to do any cleanup"""
38
+
39
+ def __str__(self):
40
+ out = f"Thread <{self.__class__.__name__}({self.name}) Alive? {self.is_alive()}>"
41
+ return out
42
+
43
+ def loop_age(self):
44
+ """How old is the last loop call?"""
45
+ return datetime.datetime.now() - self._last_loop
46
+
47
+ def run(self):
48
+ LOG.debug("Starting")
49
+ while not self._should_quit():
50
+ self.loop_count += 1
51
+ can_loop = self.loop()
52
+ self._last_loop = datetime.datetime.now()
53
+ if not can_loop:
54
+ self.stop()
55
+ self._cleanup()
56
+ APRSDThreadList().remove(self)
57
+ LOG.debug("Exiting")
58
+
59
+
60
+ class APRSDThreadList:
61
+ """Singleton class that keeps track of application wide threads."""
62
+
63
+ _instance = None
64
+
65
+ threads_list: List[APRSDThread] = []
66
+ lock = threading.Lock()
67
+
68
+ def __new__(cls, *args, **kwargs):
69
+ if cls._instance is None:
70
+ cls._instance = super().__new__(cls)
71
+ cls.threads_list = []
72
+ return cls._instance
73
+
74
+ def stats(self, serializable=False) -> dict:
75
+ stats = {}
76
+ for th in self.threads_list:
77
+ age = th.loop_age()
78
+ if serializable:
79
+ age = str(age)
80
+ stats[th.name] = {
81
+ "name": th.name,
82
+ "class": th.__class__.__name__,
83
+ "alive": th.is_alive(),
84
+ "age": th.loop_age(),
85
+ "loop_count": th.loop_count,
86
+ }
87
+ return stats
88
+
89
+ @wrapt.synchronized(lock)
90
+ def add(self, thread_obj):
91
+ self.threads_list.append(thread_obj)
92
+
93
+ @wrapt.synchronized(lock)
94
+ def remove(self, thread_obj):
95
+ self.threads_list.remove(thread_obj)
96
+
97
+ @wrapt.synchronized(lock)
98
+ def stop_all(self):
99
+ """Iterate over all threads and call stop on them."""
100
+ for th in self.threads_list:
101
+ LOG.info(f"Stopping Thread {th.name}")
102
+ if hasattr(th, "packet"):
103
+ LOG.info(F"{th.name} packet {th.packet}")
104
+ th.stop()
105
+
106
+ @wrapt.synchronized(lock)
107
+ def info(self):
108
+ """Go through all the threads and collect info about each."""
109
+ info = {}
110
+ for thread in self.threads_list:
111
+ alive = thread.is_alive()
112
+ age = thread.loop_age()
113
+ key = thread.__class__.__name__
114
+ info[key] = {"alive": True if alive else False, "age": age, "name": thread.name}
115
+ return info
116
+
117
+ @wrapt.synchronized(lock)
118
+ def __len__(self):
119
+ return len(self.threads_list)