aprsd 3.3.3__py2.py3-none-any.whl → 3.4.0__py2.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 (72) hide show
  1. aprsd/client.py +133 -20
  2. aprsd/clients/aprsis.py +6 -3
  3. aprsd/clients/fake.py +1 -1
  4. aprsd/clients/kiss.py +1 -1
  5. aprsd/cmds/completion.py +13 -27
  6. aprsd/cmds/fetch_stats.py +53 -57
  7. aprsd/cmds/healthcheck.py +32 -30
  8. aprsd/cmds/list_plugins.py +2 -2
  9. aprsd/cmds/listen.py +33 -17
  10. aprsd/cmds/send_message.py +2 -2
  11. aprsd/cmds/server.py +26 -9
  12. aprsd/cmds/webchat.py +34 -29
  13. aprsd/conf/common.py +46 -31
  14. aprsd/log/log.py +28 -6
  15. aprsd/main.py +20 -18
  16. aprsd/packets/__init__.py +3 -2
  17. aprsd/packets/collector.py +56 -0
  18. aprsd/packets/core.py +456 -321
  19. aprsd/packets/log.py +143 -0
  20. aprsd/packets/packet_list.py +83 -66
  21. aprsd/packets/seen_list.py +30 -19
  22. aprsd/packets/tracker.py +60 -62
  23. aprsd/packets/watch_list.py +64 -38
  24. aprsd/plugin.py +41 -16
  25. aprsd/plugins/email.py +35 -7
  26. aprsd/plugins/time.py +3 -2
  27. aprsd/plugins/version.py +4 -5
  28. aprsd/plugins/weather.py +0 -1
  29. aprsd/stats/__init__.py +20 -0
  30. aprsd/stats/app.py +46 -0
  31. aprsd/stats/collector.py +38 -0
  32. aprsd/threads/__init__.py +3 -2
  33. aprsd/threads/aprsd.py +67 -36
  34. aprsd/threads/keep_alive.py +55 -49
  35. aprsd/threads/log_monitor.py +46 -0
  36. aprsd/threads/rx.py +43 -24
  37. aprsd/threads/stats.py +44 -0
  38. aprsd/threads/tx.py +36 -17
  39. aprsd/utils/__init__.py +12 -0
  40. aprsd/utils/counter.py +6 -3
  41. aprsd/utils/json.py +20 -0
  42. aprsd/utils/objectstore.py +22 -17
  43. aprsd/web/admin/static/css/prism.css +4 -189
  44. aprsd/web/admin/static/js/charts.js +9 -7
  45. aprsd/web/admin/static/js/echarts.js +71 -9
  46. aprsd/web/admin/static/js/main.js +47 -6
  47. aprsd/web/admin/static/js/prism.js +11 -2246
  48. aprsd/web/admin/templates/index.html +18 -7
  49. aprsd/web/chat/static/js/gps.js +3 -1
  50. aprsd/web/chat/static/js/main.js +4 -3
  51. aprsd/web/chat/static/js/send-message.js +5 -2
  52. aprsd/web/chat/templates/index.html +1 -0
  53. aprsd/wsgi.py +62 -127
  54. {aprsd-3.3.3.dist-info → aprsd-3.4.0.dist-info}/METADATA +14 -16
  55. {aprsd-3.3.3.dist-info → aprsd-3.4.0.dist-info}/RECORD +60 -65
  56. {aprsd-3.3.3.dist-info → aprsd-3.4.0.dist-info}/WHEEL +1 -1
  57. aprsd-3.4.0.dist-info/pbr.json +1 -0
  58. aprsd/plugins/query.py +0 -81
  59. aprsd/rpc/__init__.py +0 -14
  60. aprsd/rpc/client.py +0 -165
  61. aprsd/rpc/server.py +0 -99
  62. aprsd/stats.py +0 -266
  63. aprsd/threads/store.py +0 -30
  64. aprsd/utils/converters.py +0 -15
  65. aprsd/web/admin/static/json-viewer/jquery.json-viewer.css +0 -57
  66. aprsd/web/admin/static/json-viewer/jquery.json-viewer.js +0 -158
  67. aprsd/web/chat/static/json-viewer/jquery.json-viewer.css +0 -57
  68. aprsd/web/chat/static/json-viewer/jquery.json-viewer.js +0 -158
  69. aprsd-3.3.3.dist-info/pbr.json +0 -1
  70. {aprsd-3.3.3.dist-info → aprsd-3.4.0.dist-info}/LICENSE +0 -0
  71. {aprsd-3.3.3.dist-info → aprsd-3.4.0.dist-info}/entry_points.txt +0 -0
  72. {aprsd-3.3.3.dist-info → aprsd-3.4.0.dist-info}/top_level.txt +0 -0
aprsd/packets/core.py CHANGED
@@ -1,30 +1,40 @@
1
- import abc
2
- from dataclasses import asdict, dataclass, field
1
+ from dataclasses import dataclass, field
3
2
  from datetime import datetime
4
3
  import logging
5
4
  import re
6
5
  import time
7
6
  # Due to a failure in python 3.8
8
- from typing import List
7
+ from typing import Any, List, Optional, Type, TypeVar, Union
9
8
 
10
- import dacite
11
- from dataclasses_json import dataclass_json
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
12
14
 
13
15
  from aprsd.utils import counter
14
16
 
15
17
 
16
- LOG = logging.getLogger("APRSD")
18
+ # For mypy to be happy
19
+ A = TypeVar("A", bound="DataClassJsonMixin")
20
+ Json = Union[dict, list, str, int, float, bool, None]
17
21
 
22
+ LOG = logging.getLogger()
23
+ LOGU = logger
24
+
25
+ PACKET_TYPE_BULLETIN = "bulletin"
18
26
  PACKET_TYPE_MESSAGE = "message"
19
27
  PACKET_TYPE_ACK = "ack"
20
28
  PACKET_TYPE_REJECT = "reject"
21
29
  PACKET_TYPE_MICE = "mic-e"
22
- PACKET_TYPE_WX = "weather"
30
+ PACKET_TYPE_WX = "wx"
31
+ PACKET_TYPE_WEATHER = "weather"
23
32
  PACKET_TYPE_OBJECT = "object"
24
33
  PACKET_TYPE_UNKNOWN = "unknown"
25
34
  PACKET_TYPE_STATUS = "status"
26
35
  PACKET_TYPE_BEACON = "beacon"
27
36
  PACKET_TYPE_THIRDPARTY = "thirdparty"
37
+ PACKET_TYPE_TELEMETRY = "telemetry-message"
28
38
  PACKET_TYPE_UNCOMPRESSED = "uncompressed"
29
39
 
30
40
  NO_DATE = datetime(1900, 10, 24)
@@ -52,68 +62,54 @@ def _init_msgNo(): # noqa: N802
52
62
  return c.value
53
63
 
54
64
 
55
- def factory_from_dict(packet_dict):
56
- pkt_type = get_packet_type(packet_dict)
57
- if pkt_type:
58
- cls = TYPE_LOOKUP[pkt_type]
59
- return cls.from_dict(packet_dict)
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]
60
75
 
76
+ # addresse overrides to_call
77
+ if "addresse" in raw:
78
+ raw["to_call"] = raw["addresse"]
61
79
 
62
- def factory_from_json(packet_dict):
63
- pkt_type = get_packet_type(packet_dict)
64
- if pkt_type:
65
- return TYPE_LOOKUP[pkt_type].from_json(packet_dict)
80
+ return raw
66
81
 
67
82
 
68
83
  @dataclass_json
69
84
  @dataclass(unsafe_hash=True)
70
- class Packet(metaclass=abc.ABCMeta):
71
- from_call: str = field(default=None)
72
- to_call: str = field(default=None)
73
- addresse: str = field(default=None)
74
- format: str = field(default=None)
75
- msgNo: str = field(default_factory=_init_msgNo) # noqa: N815
76
- packet_type: str = field(default=None)
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)
77
94
  timestamp: float = field(default_factory=_init_timestamp, compare=False, hash=False)
78
95
  # Holds the raw text string to be sent over the wire
79
96
  # or holds the raw string from input packet
80
- raw: str = field(default=None, compare=False, hash=False)
97
+ raw: Optional[str] = field(default=None, compare=False, hash=False)
81
98
  raw_dict: dict = field(repr=False, default_factory=lambda: {}, compare=False, hash=False)
82
99
  # Built by calling prepare(). raw needs this built first.
83
- payload: str = field(default=None)
100
+ payload: Optional[str] = field(default=None)
84
101
 
85
102
  # Fields related to sending packets out
86
103
  send_count: int = field(repr=False, default=0, compare=False, hash=False)
87
104
  retry_count: int = field(repr=False, default=3, compare=False, hash=False)
88
- # last_send_time: datetime = field(
89
- # metadata=dc_json_config(
90
- # encoder=datetime.isoformat,
91
- # decoder=datetime.fromisoformat,
92
- # ),
93
- # repr=True,
94
- # default_factory=_init_send_time,
95
- # compare=False,
96
- # hash=False
97
- # )
98
105
  last_send_time: float = field(repr=False, default=0, compare=False, hash=False)
99
- last_send_attempt: int = field(repr=False, default=0, compare=False, hash=False)
100
106
 
101
107
  # Do we allow this packet to be saved to send later?
102
108
  allow_delay: bool = field(repr=False, default=True, compare=False, hash=False)
103
109
  path: List[str] = field(default_factory=list, compare=False, hash=False)
104
- via: str = field(default=None, compare=False, hash=False)
105
-
106
- def __post__init__(self):
107
- LOG.warning(f"POST INIT {self}")
108
-
109
- @property
110
- def json(self):
111
- """
112
- get the json formated string
113
- """
114
- return self.to_json()
110
+ via: Optional[str] = field(default=None, compare=False, hash=False)
115
111
 
116
- def get(self, key, default=None):
112
+ def get(self, key: str, default: Optional[str] = None):
117
113
  """Emulate a getter on a dict."""
118
114
  if hasattr(self, key):
119
115
  return getattr(self, key)
@@ -121,342 +117,222 @@ class Packet(metaclass=abc.ABCMeta):
121
117
  return default
122
118
 
123
119
  @property
124
- def key(self):
120
+ def key(self) -> str:
125
121
  """Build a key for finding this packet in a dict."""
126
122
  return f"{self.from_call}:{self.addresse}:{self.msgNo}"
127
123
 
128
- def update_timestamp(self):
124
+ def update_timestamp(self) -> None:
129
125
  self.timestamp = _init_timestamp()
130
126
 
131
- def prepare(self):
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:
132
139
  """Do stuff here that is needed prior to sending over the air."""
133
140
  # now build the raw message for sending
141
+ if not self.msgNo:
142
+ self.msgNo = _init_msgNo()
134
143
  self._build_payload()
135
144
  self._build_raw()
136
145
 
137
- def _build_payload(self):
146
+ def _build_payload(self) -> None:
138
147
  """The payload is the non headers portion of the packet."""
139
- msg = self._filter_for_send().rstrip("\n")
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
140
152
  self.payload = (
141
153
  f":{self.to_call.ljust(9)}"
142
- f":{msg}"
143
154
  )
144
155
 
145
- def _build_raw(self):
156
+ def _build_raw(self) -> None:
146
157
  """Build the self.raw which is what is sent over the air."""
147
158
  self.raw = "{}>APZ100:{}".format(
148
159
  self.from_call,
149
160
  self.payload,
150
161
  )
151
162
 
152
- @staticmethod
153
- def factory(raw_packet):
154
- """Factory method to create a packet from a raw packet string."""
155
- raw = raw_packet
156
- raw["raw_dict"] = raw.copy()
157
- translate_fields = {
158
- "from": "from_call",
159
- "to": "to_call",
160
- }
161
- # First translate some fields
162
- for key in translate_fields:
163
- if key in raw:
164
- raw[translate_fields[key]] = raw[key]
165
- del raw[key]
166
-
167
- if "addresse" in raw:
168
- raw["to_call"] = raw["addresse"]
169
-
170
- packet_type = get_packet_type(raw)
171
- raw["packet_type"] = packet_type
172
- class_name = TYPE_LOOKUP[packet_type]
173
- if packet_type == PACKET_TYPE_THIRDPARTY:
174
- # We have an encapsulated packet!
175
- # So we need to decode it and return the inner packet
176
- # as the packet we are going to process.
177
- # This is a recursive call to the factory
178
- subpacket_raw = raw["subpacket"]
179
- subpacket = Packet.factory(subpacket_raw)
180
- del raw["subpacket"]
181
- # raw["subpacket"] = subpacket
182
- packet = dacite.from_dict(data_class=class_name, data=raw)
183
- packet.subpacket = subpacket
184
- return packet
185
-
186
- if packet_type == PACKET_TYPE_UNKNOWN:
187
- # Try and figure it out here
188
- if "latitude" in raw:
189
- class_name = GPSPacket
190
-
191
- if packet_type == PACKET_TYPE_WX:
192
- # the weather information is in a dict
193
- # this brings those values out to the outer dict
194
-
195
- for key in raw["weather"]:
196
- raw[key] = raw["weather"][key]
197
-
198
- # If we have the broken aprslib, then we need to
199
- # Convert the course and speed to wind_speed and wind_direction
200
- # aprslib issue #80
201
- # https://github.com/rossengeorgiev/aprs-python/issues/80
202
- # Wind speed and course is option in the SPEC.
203
- # For some reason aprslib multiplies the speed by 1.852.
204
- if "wind_speed" not in raw and "wind_direction" not in raw:
205
- # Most likely this is the broken aprslib
206
- # So we need to convert the wind_gust speed
207
- raw["wind_gust"] = round(raw.get("wind_gust", 0) / 0.44704, 3)
208
- if "wind_speed" not in raw:
209
- wind_speed = raw.get("speed")
210
- if wind_speed:
211
- raw["wind_speed"] = round(wind_speed / 1.852, 3)
212
- raw["weather"]["wind_speed"] = raw["wind_speed"]
213
- if "speed" in raw:
214
- del raw["speed"]
215
- # Let's adjust the rain numbers as well, since it's wrong
216
- raw["rain_1h"] = round((raw.get("rain_1h", 0) / .254) * .01, 3)
217
- raw["weather"]["rain_1h"] = raw["rain_1h"]
218
- raw["rain_24h"] = round((raw.get("rain_24h", 0) / .254) * .01, 3)
219
- raw["weather"]["rain_24h"] = raw["rain_24h"]
220
- raw["rain_since_midnight"] = round((raw.get("rain_since_midnight", 0) / .254) * .01, 3)
221
- raw["weather"]["rain_since_midnight"] = raw["rain_since_midnight"]
222
-
223
- if "wind_direction" not in raw:
224
- wind_direction = raw.get("course")
225
- if wind_direction:
226
- raw["wind_direction"] = wind_direction
227
- raw["weather"]["wind_direction"] = raw["wind_direction"]
228
- if "course" in raw:
229
- del raw["course"]
230
-
231
- return dacite.from_dict(data_class=class_name, data=raw)
232
-
233
- def log(self, header=None):
234
- """LOG a packet to the logfile."""
235
- asdict(self)
236
- log_list = ["\n"]
237
- name = self.__class__.__name__
238
- if header:
239
- if "tx" in header.lower():
240
- log_list.append(
241
- f"{header}________({name} "
242
- f"TX:{self.send_count+1} of {self.retry_count})",
243
- )
244
- else:
245
- log_list.append(f"{header}________({name})")
246
- # log_list.append(f" Packet : {self.__class__.__name__}")
247
- log_list.append(f" Raw : {self.raw}")
248
- if self.to_call:
249
- log_list.append(f" To : {self.to_call}")
250
- if self.from_call:
251
- log_list.append(f" From : {self.from_call}")
252
- if hasattr(self, "path") and self.path:
253
- log_list.append(f" Path : {'=>'.join(self.path)}")
254
- if hasattr(self, "via") and self.via:
255
- log_list.append(f" VIA : {self.via}")
256
-
257
- elif isinstance(self, MessagePacket):
258
- log_list.append(f" Message : {self.message_text}")
259
-
260
- if hasattr(self, "comment") and self.comment:
261
- log_list.append(f" Comment : {self.comment}")
262
-
263
- if self.msgNo:
264
- log_list.append(f" Msg # : {self.msgNo}")
265
- log_list.append(f"{header}________({name})")
266
-
267
- LOG.info("\n".join(log_list))
268
- LOG.debug(repr(self))
269
-
270
- def _filter_for_send(self) -> str:
163
+ def _filter_for_send(self, msg) -> str:
271
164
  """Filter and format message string for FCC."""
272
165
  # max? ftm400 displays 64, raw msg shows 74
273
166
  # and ftm400-send is max 64. setting this to
274
167
  # 67 displays 64 on the ftm400. (+3 {01 suffix)
275
168
  # feature req: break long ones into two msgs
276
- message = self.raw[:67]
169
+ if not msg:
170
+ return ""
171
+
172
+ message = msg[:67]
277
173
  # We all miss George Carlin
278
- return re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message)
174
+ return re.sub(
175
+ "fuck|shit|cunt|piss|cock|bitch", "****",
176
+ message, flags=re.IGNORECASE,
177
+ )
279
178
 
280
- def __str__(self):
179
+ def __str__(self) -> str:
281
180
  """Show the raw version of the packet"""
282
181
  self.prepare()
182
+ if not self.raw:
183
+ raise ValueError("self.raw is unset")
283
184
  return self.raw
284
185
 
285
- def __repr__(self):
186
+ def __repr__(self) -> str:
286
187
  """Build the repr version of the packet."""
287
188
  repr = (
288
189
  f"{self.__class__.__name__}:"
289
190
  f" From: {self.from_call} "
290
- " To: "
191
+ f" To: {self.to_call}"
291
192
  )
292
-
293
193
  return repr
294
194
 
295
195
 
196
+ @dataclass_json
296
197
  @dataclass(unsafe_hash=True)
297
198
  class AckPacket(Packet):
298
- response: str = field(default=None)
299
-
300
- def __post__init__(self):
301
- if self.response:
302
- LOG.warning("Response set!")
199
+ _type: str = field(default="AckPacket", hash=False)
303
200
 
304
201
  def _build_payload(self):
305
- self.payload = f":{self.to_call.ljust(9)}:ack{self.msgNo}"
202
+ self.payload = f":{self.to_call: <9}:ack{self.msgNo}"
306
203
 
307
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
308
230
  @dataclass(unsafe_hash=True)
309
231
  class RejectPacket(Packet):
310
- response: str = field(default=None)
232
+ _type: str = field(default="RejectPacket", hash=False)
233
+ response: Optional[str] = field(default=None)
311
234
 
312
235
  def __post__init__(self):
313
236
  if self.response:
314
237
  LOG.warning("Response set!")
315
238
 
316
239
  def _build_payload(self):
317
- self.payload = f":{self.to_call.ljust(9)} :rej{self.msgNo}"
240
+ self.payload = f":{self.to_call: <9}:rej{self.msgNo}"
318
241
 
319
242
 
320
243
  @dataclass_json
321
244
  @dataclass(unsafe_hash=True)
322
245
  class MessagePacket(Packet):
323
- message_text: str = field(default=None)
246
+ _type: str = field(default="MessagePacket", hash=False)
247
+ message_text: Optional[str] = field(default=None)
324
248
 
325
- def _filter_for_send(self) -> str:
326
- """Filter and format message string for FCC."""
327
- # max? ftm400 displays 64, raw msg shows 74
328
- # and ftm400-send is max 64. setting this to
329
- # 67 displays 64 on the ftm400. (+3 {01 suffix)
330
- # feature req: break long ones into two msgs
331
- message = self.message_text[:67]
332
- # We all miss George Carlin
333
- return re.sub("fuck|shit|cunt|piss|cock|bitch", "****", message)
249
+ @property
250
+ def human_info(self) -> str:
251
+ self.prepare()
252
+ return self._filter_for_send(self.message_text).rstrip("\n")
334
253
 
335
254
  def _build_payload(self):
336
255
  self.payload = ":{}:{}{{{}".format(
337
256
  self.to_call.ljust(9),
338
- self._filter_for_send().rstrip("\n"),
257
+ self._filter_for_send(self.message_text).rstrip("\n"),
339
258
  str(self.msgNo),
340
259
  )
341
260
 
342
261
 
262
+ @dataclass_json
343
263
  @dataclass(unsafe_hash=True)
344
264
  class StatusPacket(Packet):
345
- status: str = field(default=None)
265
+ _type: str = field(default="StatusPacket", hash=False)
266
+ status: Optional[str] = field(default=None)
346
267
  messagecapable: bool = field(default=False)
347
- comment: str = field(default=None)
268
+ comment: Optional[str] = field(default=None)
269
+ raw_timestamp: Optional[str] = field(default=None)
348
270
 
349
271
  def _build_payload(self):
350
- raise NotImplementedError
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
351
282
 
352
283
 
284
+ @dataclass_json
353
285
  @dataclass(unsafe_hash=True)
354
286
  class GPSPacket(Packet):
287
+ _type: str = field(default="GPSPacket", hash=False)
355
288
  latitude: float = field(default=0.00)
356
289
  longitude: float = field(default=0.00)
357
290
  altitude: float = field(default=0.00)
358
291
  rng: float = field(default=0.00)
359
292
  posambiguity: int = field(default=0)
360
- comment: str = field(default=None)
293
+ messagecapable: bool = field(default=False)
294
+ comment: Optional[str] = field(default=None)
361
295
  symbol: str = field(default="l")
362
296
  symbol_table: str = field(default="/")
363
-
364
- def decdeg2dms(self, degrees_decimal):
365
- is_positive = degrees_decimal >= 0
366
- degrees_decimal = abs(degrees_decimal)
367
- minutes, seconds = divmod(degrees_decimal * 3600, 60)
368
- degrees, minutes = divmod(minutes, 60)
369
- degrees = degrees if is_positive else -degrees
370
-
371
- degrees = str(int(degrees)).replace("-", "0")
372
- minutes = str(int(minutes)).replace("-", "0")
373
- seconds = str(int(round(seconds * 0.01, 2) * 100))
374
-
375
- return {"degrees": degrees, "minutes": minutes, "seconds": seconds}
376
-
377
- def decdeg2dmm_m(self, degrees_decimal):
378
- is_positive = degrees_decimal >= 0
379
- degrees_decimal = abs(degrees_decimal)
380
- minutes, seconds = divmod(degrees_decimal * 3600, 60)
381
- degrees, minutes = divmod(minutes, 60)
382
- degrees = degrees if is_positive else -degrees
383
-
384
- degrees = abs(int(degrees))
385
- minutes = int(round(minutes + (seconds / 60), 2))
386
- hundredths = round(seconds / 60, 2)
387
-
388
- return {
389
- "degrees": degrees, "minutes": minutes, "seconds": seconds,
390
- "hundredths": hundredths,
391
- }
392
-
393
- def convert_latitude(self, degrees_decimal):
394
- det = self.decdeg2dmm_m(degrees_decimal)
395
- if degrees_decimal > 0:
396
- direction = "N"
397
- else:
398
- direction = "S"
399
-
400
- degrees = str(det.get("degrees")).zfill(2)
401
- minutes = str(det.get("minutes")).zfill(2)
402
- seconds = det.get("seconds")
403
- hun = det.get("hundredths")
404
- hundredths = f"{hun:.2f}".split(".")[1]
405
-
406
- LOG.debug(
407
- f"LAT degress {degrees} minutes {str(minutes)} "
408
- f"seconds {seconds} hundredths {hundredths} direction {direction}",
409
- )
410
-
411
- lat = f"{degrees}{str(minutes)}.{hundredths}{direction}"
412
- return lat
413
-
414
- def convert_longitude(self, degrees_decimal):
415
- det = self.decdeg2dmm_m(degrees_decimal)
416
- if degrees_decimal > 0:
417
- direction = "E"
418
- else:
419
- direction = "W"
420
-
421
- degrees = str(det.get("degrees")).zfill(3)
422
- minutes = str(det.get("minutes")).zfill(2)
423
- seconds = det.get("seconds")
424
- hun = det.get("hundredths")
425
- hundredths = f"{hun:.2f}".split(".")[1]
426
-
427
- LOG.debug(
428
- f"LON degress {degrees} minutes {str(minutes)} "
429
- f"seconds {seconds} hundredths {hundredths} direction {direction}",
430
- )
431
-
432
- lon = f"{degrees}{str(minutes)}.{hundredths}{direction}"
433
- return lon
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)
434
312
 
435
313
  def _build_time_zulu(self):
436
314
  """Build the timestamp in UTC/zulu."""
437
315
  if self.timestamp:
438
- local_dt = datetime.fromtimestamp(self.timestamp)
439
- else:
440
- local_dt = datetime.now()
441
- self.timestamp = datetime.timestamp(local_dt)
442
-
443
- utc_offset_timedelta = datetime.utcnow() - local_dt
444
- result_utc_datetime = local_dt + utc_offset_timedelta
445
- time_zulu = result_utc_datetime.strftime("%d%H%M")
446
- return time_zulu
316
+ return datetime.utcfromtimestamp(self.timestamp).strftime("%d%H%M")
447
317
 
448
318
  def _build_payload(self):
449
319
  """The payload is the non headers portion of the packet."""
450
320
  time_zulu = self._build_time_zulu()
451
- lat = self.latitude
452
- long = self.longitude
453
- self.payload = (
454
- f"@{time_zulu}z{lat}{self.symbol_table}"
455
- f"{long}{self.symbol}"
456
- )
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
+ ]
457
331
 
458
332
  if self.comment:
459
- self.payload = f"{self.payload}{self.comment}"
333
+ payload.append(self._filter_for_send(self.comment))
334
+
335
+ self.payload = "".join(payload)
460
336
 
461
337
  def _build_raw(self):
462
338
  self.raw = (
@@ -464,45 +340,129 @@ class GPSPacket(Packet):
464
340
  f"{self.payload}"
465
341
  )
466
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)
467
360
 
361
+
362
+ @dataclass_json
468
363
  @dataclass(unsafe_hash=True)
469
364
  class BeaconPacket(GPSPacket):
365
+ _type: str = field(default="BeaconPacket", hash=False)
366
+
470
367
  def _build_payload(self):
471
368
  """The payload is the non headers portion of the packet."""
472
369
  time_zulu = self._build_time_zulu()
473
- lat = self.convert_latitude(self.latitude)
474
- long = self.convert_longitude(self.longitude)
370
+ lat = aprslib_util.latitude_to_ddm(self.latitude)
371
+ lon = aprslib_util.longitude_to_ddm(self.longitude)
475
372
 
476
373
  self.payload = (
477
374
  f"@{time_zulu}z{lat}{self.symbol_table}"
478
- f"{long}{self.symbol}APRSD Beacon"
375
+ f"{lon}"
479
376
  )
480
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
+
481
384
  def _build_raw(self):
482
385
  self.raw = (
483
386
  f"{self.from_call}>APZ100:"
484
387
  f"{self.payload}"
485
388
  )
486
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
+
487
406
 
488
- @dataclass
407
+ @dataclass_json
408
+ @dataclass(unsafe_hash=True)
489
409
  class MicEPacket(GPSPacket):
410
+ _type: str = field(default="MicEPacket", hash=False)
490
411
  messagecapable: bool = False
491
- mbits: str = None
492
- mtype: str = None
412
+ mbits: Optional[str] = None
413
+ mtype: Optional[str] = None
414
+ telemetry: Optional[dict] = field(default=None)
493
415
  # in MPH
494
416
  speed: float = 0.00
495
417
  # 0 to 360
496
418
  course: int = 0
497
419
 
498
- def _build_payload(self):
499
- raise NotImplementedError
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"
500
429
 
501
430
 
502
- @dataclass
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)
503
462
  class ObjectPacket(GPSPacket):
463
+ _type: str = field(default="ObjectPacket", hash=False)
504
464
  alive: bool = True
505
- raw_timestamp: str = None
465
+ raw_timestamp: Optional[str] = None
506
466
  symbol: str = field(default="r")
507
467
  # in MPH
508
468
  speed: float = 0.00
@@ -511,8 +471,8 @@ class ObjectPacket(GPSPacket):
511
471
 
512
472
  def _build_payload(self):
513
473
  time_zulu = self._build_time_zulu()
514
- lat = self.convert_latitude(self.latitude)
515
- long = self.convert_longitude(self.longitude)
474
+ lat = aprslib_util.latitude_to_ddm(self.latitude)
475
+ long = aprslib_util.longitude_to_ddm(self.longitude)
516
476
 
517
477
  self.payload = (
518
478
  f"*{time_zulu}z{lat}{self.symbol_table}"
@@ -520,7 +480,8 @@ class ObjectPacket(GPSPacket):
520
480
  )
521
481
 
522
482
  if self.comment:
523
- self.payload = f"{self.payload}{self.comment}"
483
+ comment = self._filter_for_send(self.comment)
484
+ self.payload = f"{self.payload}{comment}"
524
485
 
525
486
  def _build_raw(self):
526
487
  """
@@ -538,9 +499,15 @@ class ObjectPacket(GPSPacket):
538
499
  f"{self.payload}"
539
500
  )
540
501
 
502
+ @property
503
+ def human_info(self) -> str:
504
+ h_info = super().human_info
505
+ return f"{h_info} {self.comment}"
506
+
541
507
 
542
- @dataclass()
543
- class WeatherPacket(GPSPacket):
508
+ @dataclass(unsafe_hash=True)
509
+ class WeatherPacket(GPSPacket, DataClassJsonMixin):
510
+ _type: str = field(default="WeatherPacket", hash=False)
544
511
  symbol: str = "_"
545
512
  wind_speed: float = 0.00
546
513
  wind_direction: int = 0
@@ -552,7 +519,76 @@ class WeatherPacket(GPSPacket):
552
519
  rain_since_midnight: float = 0.00
553
520
  humidity: int = 0
554
521
  pressure: float = 0.00
555
- comment: str = None
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)
556
592
 
557
593
  def _build_payload(self):
558
594
  """Build an uncompressed weather packet
@@ -603,7 +639,8 @@ class WeatherPacket(GPSPacket):
603
639
  f"b{self.pressure:05.0f}",
604
640
  ]
605
641
  if self.comment:
606
- contents.append(self.comment)
642
+ comment = self.filter_for_send(self.comment)
643
+ contents.append(comment)
607
644
  self.payload = "".join(contents)
608
645
 
609
646
  def _build_raw(self):
@@ -614,9 +651,11 @@ class WeatherPacket(GPSPacket):
614
651
  )
615
652
 
616
653
 
617
- class ThirdParty(Packet):
654
+ @dataclass(unsafe_hash=True)
655
+ class ThirdPartyPacket(Packet, DataClassJsonMixin):
656
+ _type: str = "ThirdPartyPacket"
618
657
  # Holds the encapsulated packet
619
- subpacket: Packet = field(default=None, compare=True, hash=False)
658
+ subpacket: Optional[type[Packet]] = field(default=None, compare=True, hash=False)
620
659
 
621
660
  def __repr__(self):
622
661
  """Build the repr version of the packet."""
@@ -629,26 +668,74 @@ class ThirdParty(Packet):
629
668
 
630
669
  return repr_str
631
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.
632
692
 
633
- TYPE_LOOKUP = {
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,
634
719
  PACKET_TYPE_WX: WeatherPacket,
720
+ PACKET_TYPE_WEATHER: WeatherPacket,
635
721
  PACKET_TYPE_MESSAGE: MessagePacket,
636
722
  PACKET_TYPE_ACK: AckPacket,
637
723
  PACKET_TYPE_REJECT: RejectPacket,
638
724
  PACKET_TYPE_MICE: MicEPacket,
639
725
  PACKET_TYPE_OBJECT: ObjectPacket,
640
726
  PACKET_TYPE_STATUS: StatusPacket,
641
- PACKET_TYPE_BEACON: GPSPacket,
642
- PACKET_TYPE_UNKNOWN: Packet,
643
- PACKET_TYPE_THIRDPARTY: ThirdParty,
727
+ PACKET_TYPE_BEACON: BeaconPacket,
728
+ PACKET_TYPE_UNKNOWN: UnknownPacket,
729
+ PACKET_TYPE_THIRDPARTY: ThirdPartyPacket,
730
+ PACKET_TYPE_TELEMETRY: TelemetryPacket,
644
731
  }
645
732
 
646
733
 
647
- def get_packet_type(packet: dict):
734
+ def get_packet_type(packet: dict) -> str:
648
735
  """Decode the packet type from the packet."""
649
736
 
650
- pkt_format = packet.get("format", None)
651
- msg_response = packet.get("response", None)
737
+ pkt_format = packet.get("format")
738
+ msg_response = packet.get("response")
652
739
  packet_type = PACKET_TYPE_UNKNOWN
653
740
  if pkt_format == "message" and msg_response == "ack":
654
741
  packet_type = PACKET_TYPE_ACK
@@ -662,27 +749,75 @@ def get_packet_type(packet: dict):
662
749
  packet_type = PACKET_TYPE_OBJECT
663
750
  elif pkt_format == "status":
664
751
  packet_type = PACKET_TYPE_STATUS
752
+ elif pkt_format == PACKET_TYPE_BULLETIN:
753
+ packet_type = PACKET_TYPE_BULLETIN
665
754
  elif pkt_format == PACKET_TYPE_BEACON:
666
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
667
760
  elif pkt_format == PACKET_TYPE_UNCOMPRESSED:
668
- if packet.get("symbol", None) == "_":
669
- packet_type = PACKET_TYPE_WX
761
+ if packet.get("symbol") == "_":
762
+ packet_type = PACKET_TYPE_WEATHER
670
763
  elif pkt_format == PACKET_TYPE_THIRDPARTY:
671
764
  packet_type = PACKET_TYPE_THIRDPARTY
672
765
 
673
766
  if packet_type == PACKET_TYPE_UNKNOWN:
674
767
  if "latitude" in packet:
675
768
  packet_type = PACKET_TYPE_BEACON
769
+ else:
770
+ packet_type = PACKET_TYPE_UNKNOWN
676
771
  return packet_type
677
772
 
678
773
 
679
- def is_message_packet(packet):
774
+ def is_message_packet(packet: dict) -> bool:
680
775
  return get_packet_type(packet) == PACKET_TYPE_MESSAGE
681
776
 
682
777
 
683
- def is_ack_packet(packet):
778
+ def is_ack_packet(packet: dict) -> bool:
684
779
  return get_packet_type(packet) == PACKET_TYPE_ACK
685
780
 
686
781
 
687
- def is_mice_packet(packet):
782
+ def is_mice_packet(packet: dict[Any, Any]) -> bool:
688
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