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/packets/core.py ADDED
@@ -0,0 +1,823 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import datetime
3
+ import logging
4
+ import re
5
+ import time
6
+ # Due to a failure in python 3.8
7
+ from typing import Any, List, Optional, Type, TypeVar, Union
8
+
9
+ from aprslib import util as aprslib_util
10
+ from dataclasses_json import (
11
+ CatchAll, DataClassJsonMixin, Undefined, dataclass_json,
12
+ )
13
+ from loguru import logger
14
+
15
+ from aprsd.utils import counter
16
+
17
+
18
+ # For mypy to be happy
19
+ A = TypeVar("A", bound="DataClassJsonMixin")
20
+ Json = Union[dict, list, str, int, float, bool, None]
21
+
22
+ LOG = logging.getLogger()
23
+ LOGU = logger
24
+
25
+ PACKET_TYPE_BULLETIN = "bulletin"
26
+ PACKET_TYPE_MESSAGE = "message"
27
+ PACKET_TYPE_ACK = "ack"
28
+ PACKET_TYPE_REJECT = "reject"
29
+ PACKET_TYPE_MICE = "mic-e"
30
+ PACKET_TYPE_WX = "wx"
31
+ PACKET_TYPE_WEATHER = "weather"
32
+ PACKET_TYPE_OBJECT = "object"
33
+ PACKET_TYPE_UNKNOWN = "unknown"
34
+ PACKET_TYPE_STATUS = "status"
35
+ PACKET_TYPE_BEACON = "beacon"
36
+ PACKET_TYPE_THIRDPARTY = "thirdparty"
37
+ PACKET_TYPE_TELEMETRY = "telemetry-message"
38
+ PACKET_TYPE_UNCOMPRESSED = "uncompressed"
39
+
40
+ NO_DATE = datetime(1900, 10, 24)
41
+
42
+
43
+ def _init_timestamp():
44
+ """Build a unix style timestamp integer"""
45
+ return int(round(time.time()))
46
+
47
+
48
+ def _init_send_time():
49
+ # We have to use a datetime here, or the json encoder
50
+ # Fails on a NoneType.
51
+ return NO_DATE
52
+
53
+
54
+ def _init_msgNo(): # noqa: N802
55
+ """For some reason __post__init doesn't get called.
56
+
57
+ So in order to initialize the msgNo field in the packet
58
+ we use this workaround.
59
+ """
60
+ c = counter.PacketCounter()
61
+ c.increment()
62
+ return c.value
63
+
64
+
65
+ def _translate_fields(raw: dict) -> dict:
66
+ translate_fields = {
67
+ "from": "from_call",
68
+ "to": "to_call",
69
+ }
70
+ # First translate some fields
71
+ for key in translate_fields:
72
+ if key in raw:
73
+ raw[translate_fields[key]] = raw[key]
74
+ del raw[key]
75
+
76
+ # addresse overrides to_call
77
+ if "addresse" in raw:
78
+ raw["to_call"] = raw["addresse"]
79
+
80
+ return raw
81
+
82
+
83
+ @dataclass_json
84
+ @dataclass(unsafe_hash=True)
85
+ class Packet:
86
+ _type: str = field(default="Packet", hash=False)
87
+ from_call: Optional[str] = field(default=None)
88
+ to_call: Optional[str] = field(default=None)
89
+ addresse: Optional[str] = field(default=None)
90
+ format: Optional[str] = field(default=None)
91
+ msgNo: Optional[str] = field(default=None) # noqa: N815
92
+ ackMsgNo: Optional[str] = field(default=None) # noqa: N815
93
+ packet_type: Optional[str] = field(default=None)
94
+ timestamp: float = field(default_factory=_init_timestamp, compare=False, hash=False)
95
+ # Holds the raw text string to be sent over the wire
96
+ # or holds the raw string from input packet
97
+ raw: Optional[str] = field(default=None, compare=False, hash=False)
98
+ raw_dict: dict = field(repr=False, default_factory=lambda: {}, compare=False, hash=False)
99
+ # Built by calling prepare(). raw needs this built first.
100
+ payload: Optional[str] = field(default=None)
101
+
102
+ # Fields related to sending packets out
103
+ send_count: int = field(repr=False, default=0, compare=False, hash=False)
104
+ retry_count: int = field(repr=False, default=3, compare=False, hash=False)
105
+ last_send_time: float = field(repr=False, default=0, compare=False, hash=False)
106
+
107
+ # Do we allow this packet to be saved to send later?
108
+ allow_delay: bool = field(repr=False, default=True, compare=False, hash=False)
109
+ path: List[str] = field(default_factory=list, compare=False, hash=False)
110
+ via: Optional[str] = field(default=None, compare=False, hash=False)
111
+
112
+ def get(self, key: str, default: Optional[str] = None):
113
+ """Emulate a getter on a dict."""
114
+ if hasattr(self, key):
115
+ return getattr(self, key)
116
+ else:
117
+ return default
118
+
119
+ @property
120
+ def key(self) -> str:
121
+ """Build a key for finding this packet in a dict."""
122
+ return f"{self.from_call}:{self.addresse}:{self.msgNo}"
123
+
124
+ def update_timestamp(self) -> None:
125
+ self.timestamp = _init_timestamp()
126
+
127
+ @property
128
+ def human_info(self) -> str:
129
+ """Build a human readable string for this packet.
130
+
131
+ This doesn't include the from to and type, but just
132
+ the human readable payload.
133
+ """
134
+ self.prepare()
135
+ msg = self._filter_for_send(self.raw).rstrip("\n")
136
+ return msg
137
+
138
+ def prepare(self) -> None:
139
+ """Do stuff here that is needed prior to sending over the air."""
140
+ # now build the raw message for sending
141
+ if not self.msgNo:
142
+ self.msgNo = _init_msgNo()
143
+ self._build_payload()
144
+ self._build_raw()
145
+
146
+ def _build_payload(self) -> None:
147
+ """The payload is the non headers portion of the packet."""
148
+ if not self.to_call:
149
+ raise ValueError("to_call isn't set. Must set to_call before calling prepare()")
150
+
151
+ # The base packet class has no real payload
152
+ self.payload = (
153
+ f":{self.to_call.ljust(9)}"
154
+ )
155
+
156
+ def _build_raw(self) -> None:
157
+ """Build the self.raw which is what is sent over the air."""
158
+ self.raw = "{}>APZ100:{}".format(
159
+ self.from_call,
160
+ self.payload,
161
+ )
162
+
163
+ def _filter_for_send(self, msg) -> str:
164
+ """Filter and format message string for FCC."""
165
+ # max? ftm400 displays 64, raw msg shows 74
166
+ # and ftm400-send is max 64. setting this to
167
+ # 67 displays 64 on the ftm400. (+3 {01 suffix)
168
+ # feature req: break long ones into two msgs
169
+ if not msg:
170
+ return ""
171
+
172
+ message = msg[:67]
173
+ # We all miss George Carlin
174
+ return re.sub(
175
+ "fuck|shit|cunt|piss|cock|bitch", "****",
176
+ message, flags=re.IGNORECASE,
177
+ )
178
+
179
+ def __str__(self) -> str:
180
+ """Show the raw version of the packet"""
181
+ self.prepare()
182
+ if not self.raw:
183
+ raise ValueError("self.raw is unset")
184
+ return self.raw
185
+
186
+ def __repr__(self) -> str:
187
+ """Build the repr version of the packet."""
188
+ repr = (
189
+ f"{self.__class__.__name__}:"
190
+ f" From: {self.from_call} "
191
+ f" To: {self.to_call}"
192
+ )
193
+ return repr
194
+
195
+
196
+ @dataclass_json
197
+ @dataclass(unsafe_hash=True)
198
+ class AckPacket(Packet):
199
+ _type: str = field(default="AckPacket", hash=False)
200
+
201
+ def _build_payload(self):
202
+ self.payload = f":{self.to_call: <9}:ack{self.msgNo}"
203
+
204
+
205
+ @dataclass_json
206
+ @dataclass(unsafe_hash=True)
207
+ class BulletinPacket(Packet):
208
+ _type: str = "BulletinPacket"
209
+ # Holds the encapsulated packet
210
+ bid: Optional[str] = field(default="1")
211
+ message_text: Optional[str] = field(default=None)
212
+
213
+ @property
214
+ def key(self) -> str:
215
+ """Build a key for finding this packet in a dict."""
216
+ return f"{self.from_call}:BLN{self.bid}"
217
+
218
+ @property
219
+ def human_info(self) -> str:
220
+ return f"BLN{self.bid} {self.message_text}"
221
+
222
+ def _build_payload(self) -> None:
223
+ self.payload = (
224
+ f":BLN{self.bid:<9}"
225
+ f":{self.message_text}"
226
+ )
227
+
228
+
229
+ @dataclass_json
230
+ @dataclass(unsafe_hash=True)
231
+ class RejectPacket(Packet):
232
+ _type: str = field(default="RejectPacket", hash=False)
233
+ response: Optional[str] = field(default=None)
234
+
235
+ def __post__init__(self):
236
+ if self.response:
237
+ LOG.warning("Response set!")
238
+
239
+ def _build_payload(self):
240
+ self.payload = f":{self.to_call: <9}:rej{self.msgNo}"
241
+
242
+
243
+ @dataclass_json
244
+ @dataclass(unsafe_hash=True)
245
+ class MessagePacket(Packet):
246
+ _type: str = field(default="MessagePacket", hash=False)
247
+ message_text: Optional[str] = field(default=None)
248
+
249
+ @property
250
+ def human_info(self) -> str:
251
+ self.prepare()
252
+ return self._filter_for_send(self.message_text).rstrip("\n")
253
+
254
+ def _build_payload(self):
255
+ self.payload = ":{}:{}{{{}".format(
256
+ self.to_call.ljust(9),
257
+ self._filter_for_send(self.message_text).rstrip("\n"),
258
+ str(self.msgNo),
259
+ )
260
+
261
+
262
+ @dataclass_json
263
+ @dataclass(unsafe_hash=True)
264
+ class StatusPacket(Packet):
265
+ _type: str = field(default="StatusPacket", hash=False)
266
+ status: Optional[str] = field(default=None)
267
+ messagecapable: bool = field(default=False)
268
+ comment: Optional[str] = field(default=None)
269
+ raw_timestamp: Optional[str] = field(default=None)
270
+
271
+ def _build_payload(self):
272
+ self.payload = ":{}:{}{{{}".format(
273
+ self.to_call.ljust(9),
274
+ self._filter_for_send(self.status).rstrip("\n"),
275
+ str(self.msgNo),
276
+ )
277
+
278
+ @property
279
+ def human_info(self) -> str:
280
+ self.prepare()
281
+ return self.status
282
+
283
+
284
+ @dataclass_json
285
+ @dataclass(unsafe_hash=True)
286
+ class GPSPacket(Packet):
287
+ _type: str = field(default="GPSPacket", hash=False)
288
+ latitude: float = field(default=0.00)
289
+ longitude: float = field(default=0.00)
290
+ altitude: float = field(default=0.00)
291
+ rng: float = field(default=0.00)
292
+ posambiguity: int = field(default=0)
293
+ messagecapable: bool = field(default=False)
294
+ comment: Optional[str] = field(default=None)
295
+ symbol: str = field(default="l")
296
+ symbol_table: str = field(default="/")
297
+ raw_timestamp: Optional[str] = field(default=None)
298
+ object_name: Optional[str] = field(default=None)
299
+ object_format: Optional[str] = field(default=None)
300
+ alive: Optional[bool] = field(default=None)
301
+ course: Optional[int] = field(default=None)
302
+ speed: Optional[float] = field(default=None)
303
+ phg: Optional[str] = field(default=None)
304
+ phg_power: Optional[int] = field(default=None)
305
+ phg_height: Optional[float] = field(default=None)
306
+ phg_gain: Optional[int] = field(default=None)
307
+ phg_dir: Optional[str] = field(default=None)
308
+ phg_range: Optional[float] = field(default=None)
309
+ phg_rate: Optional[int] = field(default=None)
310
+ # http://www.aprs.org/datum.txt
311
+ daodatumbyte: Optional[str] = field(default=None)
312
+
313
+ def _build_time_zulu(self):
314
+ """Build the timestamp in UTC/zulu."""
315
+ if self.timestamp:
316
+ return datetime.utcfromtimestamp(self.timestamp).strftime("%d%H%M")
317
+
318
+ def _build_payload(self):
319
+ """The payload is the non headers portion of the packet."""
320
+ time_zulu = self._build_time_zulu()
321
+ lat = aprslib_util.latitude_to_ddm(self.latitude)
322
+ long = aprslib_util.longitude_to_ddm(self.longitude)
323
+ payload = [
324
+ "@" if self.timestamp else "!",
325
+ time_zulu,
326
+ lat,
327
+ self.symbol_table,
328
+ long,
329
+ self.symbol,
330
+ ]
331
+
332
+ if self.comment:
333
+ payload.append(self._filter_for_send(self.comment))
334
+
335
+ self.payload = "".join(payload)
336
+
337
+ def _build_raw(self):
338
+ self.raw = (
339
+ f"{self.from_call}>{self.to_call},WIDE2-1:"
340
+ f"{self.payload}"
341
+ )
342
+
343
+ @property
344
+ def human_info(self) -> str:
345
+ h_str = []
346
+ h_str.append(f"Lat:{self.latitude:03.3f}")
347
+ h_str.append(f"Lon:{self.longitude:03.3f}")
348
+ if self.altitude:
349
+ h_str.append(f"Altitude {self.altitude:03.0f}")
350
+ if self.speed:
351
+ h_str.append(f"Speed {self.speed:03.0f}MPH")
352
+ if self.course:
353
+ h_str.append(f"Course {self.course:03.0f}")
354
+ if self.rng:
355
+ h_str.append(f"RNG {self.rng:03.0f}")
356
+ if self.phg:
357
+ h_str.append(f"PHG {self.phg}")
358
+
359
+ return " ".join(h_str)
360
+
361
+
362
+ @dataclass_json
363
+ @dataclass(unsafe_hash=True)
364
+ class BeaconPacket(GPSPacket):
365
+ _type: str = field(default="BeaconPacket", hash=False)
366
+
367
+ def _build_payload(self):
368
+ """The payload is the non headers portion of the packet."""
369
+ time_zulu = self._build_time_zulu()
370
+ lat = aprslib_util.latitude_to_ddm(self.latitude)
371
+ lon = aprslib_util.longitude_to_ddm(self.longitude)
372
+
373
+ self.payload = (
374
+ f"@{time_zulu}z{lat}{self.symbol_table}"
375
+ f"{lon}"
376
+ )
377
+
378
+ if self.comment:
379
+ comment = self._filter_for_send(self.comment)
380
+ self.payload = f"{self.payload}{self.symbol}{comment}"
381
+ else:
382
+ self.payload = f"{self.payload}{self.symbol}APRSD Beacon"
383
+
384
+ def _build_raw(self):
385
+ self.raw = (
386
+ f"{self.from_call}>APZ100:"
387
+ f"{self.payload}"
388
+ )
389
+
390
+ @property
391
+ def key(self) -> str:
392
+ """Build a key for finding this packet in a dict."""
393
+ if self.raw_timestamp:
394
+ return f"{self.from_call}:{self.raw_timestamp}"
395
+ else:
396
+ return f"{self.from_call}:{self.human_info.replace(' ','')}"
397
+
398
+ @property
399
+ def human_info(self) -> str:
400
+ h_str = []
401
+ h_str.append(f"Lat:{self.latitude:03.3f}")
402
+ h_str.append(f"Lon:{self.longitude:03.3f}")
403
+ h_str.append(f"{self.comment}")
404
+ return " ".join(h_str)
405
+
406
+
407
+ @dataclass_json
408
+ @dataclass(unsafe_hash=True)
409
+ class MicEPacket(GPSPacket):
410
+ _type: str = field(default="MicEPacket", hash=False)
411
+ messagecapable: bool = False
412
+ mbits: Optional[str] = None
413
+ mtype: Optional[str] = None
414
+ telemetry: Optional[dict] = field(default=None)
415
+ # in MPH
416
+ speed: float = 0.00
417
+ # 0 to 360
418
+ course: int = 0
419
+
420
+ @property
421
+ def key(self) -> str:
422
+ """Build a key for finding this packet in a dict."""
423
+ return f"{self.from_call}:{self.human_info.replace(' ', '')}"
424
+
425
+ @property
426
+ def human_info(self) -> str:
427
+ h_info = super().human_info
428
+ return f"{h_info} {self.mbits} mbits"
429
+
430
+
431
+ @dataclass_json
432
+ @dataclass(unsafe_hash=True)
433
+ class TelemetryPacket(GPSPacket):
434
+ _type: str = field(default="TelemetryPacket", hash=False)
435
+ messagecapable: bool = False
436
+ mbits: Optional[str] = None
437
+ mtype: Optional[str] = None
438
+ telemetry: Optional[dict] = field(default=None)
439
+ tPARM: Optional[list[str]] = field(default=None) # noqa: N815
440
+ tUNIT: Optional[list[str]] = field(default=None) # noqa: N815
441
+ # in MPH
442
+ speed: float = 0.00
443
+ # 0 to 360
444
+ course: int = 0
445
+
446
+ @property
447
+ def key(self) -> str:
448
+ """Build a key for finding this packet in a dict."""
449
+ if self.raw_timestamp:
450
+ return f"{self.from_call}:{self.raw_timestamp}"
451
+ else:
452
+ return f"{self.from_call}:{self.human_info.replace(' ','')}"
453
+
454
+ @property
455
+ def human_info(self) -> str:
456
+ h_info = super().human_info
457
+ return f"{h_info} {self.telemetry}"
458
+
459
+
460
+ @dataclass_json
461
+ @dataclass(unsafe_hash=True)
462
+ class ObjectPacket(GPSPacket):
463
+ _type: str = field(default="ObjectPacket", hash=False)
464
+ alive: bool = True
465
+ raw_timestamp: Optional[str] = None
466
+ symbol: str = field(default="r")
467
+ # in MPH
468
+ speed: float = 0.00
469
+ # 0 to 360
470
+ course: int = 0
471
+
472
+ def _build_payload(self):
473
+ time_zulu = self._build_time_zulu()
474
+ lat = aprslib_util.latitude_to_ddm(self.latitude)
475
+ long = aprslib_util.longitude_to_ddm(self.longitude)
476
+
477
+ self.payload = (
478
+ f"*{time_zulu}z{lat}{self.symbol_table}"
479
+ f"{long}{self.symbol}"
480
+ )
481
+
482
+ if self.comment:
483
+ comment = self._filter_for_send(self.comment)
484
+ self.payload = f"{self.payload}{comment}"
485
+
486
+ def _build_raw(self):
487
+ """
488
+ REPEAT builds packets like
489
+ reply = "{}>APZ100:;{:9s}*{}z{}r{:.3f}MHz {} {}".format(
490
+ fromcall, callsign, time_zulu, latlon, freq, uplink_tone, offset,
491
+ )
492
+ where fromcall is the callsign that is sending the packet
493
+ callsign is the station callsign for the object
494
+ The frequency, uplink_tone, offset is part of the comment
495
+ """
496
+
497
+ self.raw = (
498
+ f"{self.from_call}>APZ100:;{self.to_call:9s}"
499
+ f"{self.payload}"
500
+ )
501
+
502
+ @property
503
+ def human_info(self) -> str:
504
+ h_info = super().human_info
505
+ return f"{h_info} {self.comment}"
506
+
507
+
508
+ @dataclass(unsafe_hash=True)
509
+ class WeatherPacket(GPSPacket, DataClassJsonMixin):
510
+ _type: str = field(default="WeatherPacket", hash=False)
511
+ symbol: str = "_"
512
+ wind_speed: float = 0.00
513
+ wind_direction: int = 0
514
+ wind_gust: float = 0.00
515
+ temperature: float = 0.00
516
+ # in inches. 1.04 means 1.04 inches
517
+ rain_1h: float = 0.00
518
+ rain_24h: float = 0.00
519
+ rain_since_midnight: float = 0.00
520
+ humidity: int = 0
521
+ pressure: float = 0.00
522
+ comment: Optional[str] = field(default=None)
523
+ luminosity: Optional[int] = field(default=None)
524
+ wx_raw_timestamp: Optional[str] = field(default=None)
525
+ course: Optional[int] = field(default=None)
526
+ speed: Optional[float] = field(default=None)
527
+
528
+ def _translate(self, raw: dict) -> dict:
529
+ for key in raw["weather"]:
530
+ raw[key] = raw["weather"][key]
531
+
532
+ # If we have the broken aprslib, then we need to
533
+ # Convert the course and speed to wind_speed and wind_direction
534
+ # aprslib issue #80
535
+ # https://github.com/rossengeorgiev/aprs-python/issues/80
536
+ # Wind speed and course is option in the SPEC.
537
+ # For some reason aprslib multiplies the speed by 1.852.
538
+ if "wind_speed" not in raw and "wind_direction" not in raw:
539
+ # Most likely this is the broken aprslib
540
+ # So we need to convert the wind_gust speed
541
+ raw["wind_gust"] = round(raw.get("wind_gust", 0) / 0.44704, 3)
542
+ if "wind_speed" not in raw:
543
+ wind_speed = raw.get("speed")
544
+ if wind_speed:
545
+ raw["wind_speed"] = round(wind_speed / 1.852, 3)
546
+ raw["weather"]["wind_speed"] = raw["wind_speed"]
547
+ if "speed" in raw:
548
+ del raw["speed"]
549
+ # Let's adjust the rain numbers as well, since it's wrong
550
+ raw["rain_1h"] = round((raw.get("rain_1h", 0) / .254) * .01, 3)
551
+ raw["weather"]["rain_1h"] = raw["rain_1h"]
552
+ raw["rain_24h"] = round((raw.get("rain_24h", 0) / .254) * .01, 3)
553
+ raw["weather"]["rain_24h"] = raw["rain_24h"]
554
+ raw["rain_since_midnight"] = round((raw.get("rain_since_midnight", 0) / .254) * .01, 3)
555
+ raw["weather"]["rain_since_midnight"] = raw["rain_since_midnight"]
556
+
557
+ if "wind_direction" not in raw:
558
+ wind_direction = raw.get("course")
559
+ if wind_direction:
560
+ raw["wind_direction"] = wind_direction
561
+ raw["weather"]["wind_direction"] = raw["wind_direction"]
562
+ if "course" in raw:
563
+ del raw["course"]
564
+
565
+ del raw["weather"]
566
+ return raw
567
+
568
+ @classmethod
569
+ def from_dict(cls: Type[A], kvs: Json, *, infer_missing=False) -> A:
570
+ """Create from a dictionary that has come directly from aprslib parse"""
571
+ raw = cls._translate(cls, kvs) # type: ignore
572
+ return super().from_dict(raw)
573
+
574
+ @property
575
+ def key(self) -> str:
576
+ """Build a key for finding this packet in a dict."""
577
+ if self.raw_timestamp:
578
+ return f"{self.from_call}:{self.raw_timestamp}"
579
+ elif self.wx_raw_timestamp:
580
+ return f"{self.from_call}:{self.wx_raw_timestamp}"
581
+
582
+ @property
583
+ def human_info(self) -> str:
584
+ h_str = []
585
+ h_str.append(f"Temp {self.temperature:03.0f}F")
586
+ h_str.append(f"Humidity {self.humidity}%")
587
+ h_str.append(f"Wind {self.wind_speed:03.0f}MPH@{self.wind_direction}")
588
+ h_str.append(f"Pressure {self.pressure}mb")
589
+ h_str.append(f"Rain {self.rain_24h}in/24hr")
590
+
591
+ return " ".join(h_str)
592
+
593
+ def _build_payload(self):
594
+ """Build an uncompressed weather packet
595
+
596
+ Format =
597
+
598
+ _CSE/SPDgXXXtXXXrXXXpXXXPXXXhXXbXXXXX%type NEW FORMAT APRS793 June 97
599
+ NOT BACKWARD COMPATIBLE
600
+
601
+
602
+ Where: CSE/SPD is wind direction and sustained 1 minute speed
603
+ t is in degrees F
604
+
605
+ r is Rain per last 60 minutes
606
+ 1.04 inches of rain will show as r104
607
+ p is precipitation per last 24 hours (sliding 24 hour window)
608
+ P is precip per last 24 hours since midnight
609
+ b is Baro in tenths of a mb
610
+ h is humidity in percent. 00=100
611
+ g is Gust (peak winds in last 5 minutes)
612
+ # is the raw rain counter for remote WX stations
613
+ See notes on remotes below
614
+ % shows software type d=Dos, m=Mac, w=Win, etc
615
+ type shows type of WX instrument
616
+
617
+ """
618
+ time_zulu = self._build_time_zulu()
619
+
620
+ contents = [
621
+ f"@{time_zulu}z{self.latitude}{self.symbol_table}",
622
+ f"{self.longitude}{self.symbol}",
623
+ f"{self.wind_direction:03d}",
624
+ # Speed = sustained 1 minute wind speed in mph
625
+ f"{self.symbol_table}", f"{self.wind_speed:03.0f}",
626
+ # wind gust (peak wind speed in mph in the last 5 minutes)
627
+ f"g{self.wind_gust:03.0f}",
628
+ # Temperature in degrees F
629
+ f"t{self.temperature:03.0f}",
630
+ # Rainfall (in hundredths of an inch) in the last hour
631
+ f"r{self.rain_1h*100:03.0f}",
632
+ # Rainfall (in hundredths of an inch) in last 24 hours
633
+ f"p{self.rain_24h*100:03.0f}",
634
+ # Rainfall (in hundredths of an inch) since midnigt
635
+ f"P{self.rain_since_midnight*100:03.0f}",
636
+ # Humidity
637
+ f"h{self.humidity:02d}",
638
+ # Barometric pressure (in tenths of millibars/tenths of hPascal)
639
+ f"b{self.pressure:05.0f}",
640
+ ]
641
+ if self.comment:
642
+ comment = self.filter_for_send(self.comment)
643
+ contents.append(comment)
644
+ self.payload = "".join(contents)
645
+
646
+ def _build_raw(self):
647
+
648
+ self.raw = (
649
+ f"{self.from_call}>{self.to_call},WIDE1-1,WIDE2-1:"
650
+ f"{self.payload}"
651
+ )
652
+
653
+
654
+ @dataclass(unsafe_hash=True)
655
+ class ThirdPartyPacket(Packet, DataClassJsonMixin):
656
+ _type: str = "ThirdPartyPacket"
657
+ # Holds the encapsulated packet
658
+ subpacket: Optional[type[Packet]] = field(default=None, compare=True, hash=False)
659
+
660
+ def __repr__(self):
661
+ """Build the repr version of the packet."""
662
+ repr_str = (
663
+ f"{self.__class__.__name__}:"
664
+ f" From: {self.from_call} "
665
+ f" To: {self.to_call} "
666
+ f" Subpacket: {repr(self.subpacket)}"
667
+ )
668
+
669
+ return repr_str
670
+
671
+ @classmethod
672
+ def from_dict(cls: Type[A], kvs: Json, *, infer_missing=False) -> A:
673
+ obj = super().from_dict(kvs)
674
+ obj.subpacket = factory(obj.subpacket) # type: ignore
675
+ return obj
676
+
677
+ @property
678
+ def key(self) -> str:
679
+ """Build a key for finding this packet in a dict."""
680
+ return f"{self.from_call}:{self.subpacket.key}"
681
+
682
+ @property
683
+ def human_info(self) -> str:
684
+ sub_info = self.subpacket.human_info
685
+ return f"{self.from_call}->{self.to_call} {sub_info}"
686
+
687
+
688
+ @dataclass_json(undefined=Undefined.INCLUDE)
689
+ @dataclass(unsafe_hash=True)
690
+ class UnknownPacket:
691
+ """Catchall Packet for things we don't know about.
692
+
693
+ All of the unknown attributes are stored in the unknown_fields
694
+ """
695
+ unknown_fields: CatchAll
696
+ _type: str = "UnknownPacket"
697
+ from_call: Optional[str] = field(default=None)
698
+ to_call: Optional[str] = field(default=None)
699
+ msgNo: str = field(default_factory=_init_msgNo) # noqa: N815
700
+ format: Optional[str] = field(default=None)
701
+ raw: Optional[str] = field(default=None)
702
+ raw_dict: dict = field(repr=False, default_factory=lambda: {}, compare=False, hash=False)
703
+ path: List[str] = field(default_factory=list, compare=False, hash=False)
704
+ packet_type: Optional[str] = field(default=None)
705
+ via: Optional[str] = field(default=None, compare=False, hash=False)
706
+
707
+ @property
708
+ def key(self) -> str:
709
+ """Build a key for finding this packet in a dict."""
710
+ return f"{self.from_call}:{self.packet_type}:{self.to_call}"
711
+
712
+ @property
713
+ def human_info(self) -> str:
714
+ return str(self.unknown_fields)
715
+
716
+
717
+ TYPE_LOOKUP: dict[str, type[Packet]] = {
718
+ PACKET_TYPE_BULLETIN: BulletinPacket,
719
+ PACKET_TYPE_WX: WeatherPacket,
720
+ PACKET_TYPE_WEATHER: WeatherPacket,
721
+ PACKET_TYPE_MESSAGE: MessagePacket,
722
+ PACKET_TYPE_ACK: AckPacket,
723
+ PACKET_TYPE_REJECT: RejectPacket,
724
+ PACKET_TYPE_MICE: MicEPacket,
725
+ PACKET_TYPE_OBJECT: ObjectPacket,
726
+ PACKET_TYPE_STATUS: StatusPacket,
727
+ PACKET_TYPE_BEACON: BeaconPacket,
728
+ PACKET_TYPE_UNKNOWN: UnknownPacket,
729
+ PACKET_TYPE_THIRDPARTY: ThirdPartyPacket,
730
+ PACKET_TYPE_TELEMETRY: TelemetryPacket,
731
+ }
732
+
733
+
734
+ def get_packet_type(packet: dict) -> str:
735
+ """Decode the packet type from the packet."""
736
+
737
+ pkt_format = packet.get("format")
738
+ msg_response = packet.get("response")
739
+ packet_type = PACKET_TYPE_UNKNOWN
740
+ if pkt_format == "message" and msg_response == "ack":
741
+ packet_type = PACKET_TYPE_ACK
742
+ elif pkt_format == "message" and msg_response == "rej":
743
+ packet_type = PACKET_TYPE_REJECT
744
+ elif pkt_format == "message":
745
+ packet_type = PACKET_TYPE_MESSAGE
746
+ elif pkt_format == "mic-e":
747
+ packet_type = PACKET_TYPE_MICE
748
+ elif pkt_format == "object":
749
+ packet_type = PACKET_TYPE_OBJECT
750
+ elif pkt_format == "status":
751
+ packet_type = PACKET_TYPE_STATUS
752
+ elif pkt_format == PACKET_TYPE_BULLETIN:
753
+ packet_type = PACKET_TYPE_BULLETIN
754
+ elif pkt_format == PACKET_TYPE_BEACON:
755
+ packet_type = PACKET_TYPE_BEACON
756
+ elif pkt_format == PACKET_TYPE_TELEMETRY:
757
+ packet_type = PACKET_TYPE_TELEMETRY
758
+ elif pkt_format == PACKET_TYPE_WX:
759
+ packet_type = PACKET_TYPE_WEATHER
760
+ elif pkt_format == PACKET_TYPE_UNCOMPRESSED:
761
+ if packet.get("symbol") == "_":
762
+ packet_type = PACKET_TYPE_WEATHER
763
+ elif pkt_format == PACKET_TYPE_THIRDPARTY:
764
+ packet_type = PACKET_TYPE_THIRDPARTY
765
+
766
+ if packet_type == PACKET_TYPE_UNKNOWN:
767
+ if "latitude" in packet:
768
+ packet_type = PACKET_TYPE_BEACON
769
+ else:
770
+ packet_type = PACKET_TYPE_UNKNOWN
771
+ return packet_type
772
+
773
+
774
+ def is_message_packet(packet: dict) -> bool:
775
+ return get_packet_type(packet) == PACKET_TYPE_MESSAGE
776
+
777
+
778
+ def is_ack_packet(packet: dict) -> bool:
779
+ return get_packet_type(packet) == PACKET_TYPE_ACK
780
+
781
+
782
+ def is_mice_packet(packet: dict[Any, Any]) -> bool:
783
+ return get_packet_type(packet) == PACKET_TYPE_MICE
784
+
785
+
786
+ def factory(raw_packet: dict[Any, Any]) -> type[Packet]:
787
+ """Factory method to create a packet from a raw packet string."""
788
+ raw = raw_packet
789
+ if "_type" in raw:
790
+ cls = globals()[raw["_type"]]
791
+ return cls.from_dict(raw)
792
+
793
+ raw["raw_dict"] = raw.copy()
794
+ raw = _translate_fields(raw)
795
+
796
+ packet_type = get_packet_type(raw)
797
+
798
+ raw["packet_type"] = packet_type
799
+ packet_class = TYPE_LOOKUP[packet_type]
800
+ if packet_type == PACKET_TYPE_WX:
801
+ # the weather information is in a dict
802
+ # this brings those values out to the outer dict
803
+ packet_class = WeatherPacket
804
+ elif packet_type == PACKET_TYPE_OBJECT and "weather" in raw:
805
+ packet_class = WeatherPacket
806
+ elif packet_type == PACKET_TYPE_UNKNOWN:
807
+ # Try and figure it out here
808
+ if "latitude" in raw:
809
+ packet_class = GPSPacket
810
+ else:
811
+ # LOG.warning(raw)
812
+ packet_class = UnknownPacket
813
+
814
+ raw.get("addresse", raw.get("to_call"))
815
+
816
+ # TODO: Find a global way to enable/disable this
817
+ # LOGU.opt(colors=True).info(
818
+ # f"factory(<green>{packet_type: <8}</green>):"
819
+ # f"(<red>{packet_class.__name__: <13}</red>): "
820
+ # f"<light-blue>{raw.get('from_call'): <9}</light-blue> -> <cyan>{to: <9}</cyan>")
821
+ # LOG.info(raw.get('msgNo'))
822
+
823
+ return packet_class().from_dict(raw) # type: ignore