ReticulumTelemetryHub 0.1.0__py3-none-any.whl → 0.143.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. reticulum_telemetry_hub/api/__init__.py +23 -0
  2. reticulum_telemetry_hub/api/models.py +323 -0
  3. reticulum_telemetry_hub/api/service.py +836 -0
  4. reticulum_telemetry_hub/api/storage.py +528 -0
  5. reticulum_telemetry_hub/api/storage_base.py +156 -0
  6. reticulum_telemetry_hub/api/storage_models.py +118 -0
  7. reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
  8. reticulum_telemetry_hub/atak_cot/base.py +277 -0
  9. reticulum_telemetry_hub/atak_cot/chat.py +506 -0
  10. reticulum_telemetry_hub/atak_cot/detail.py +235 -0
  11. reticulum_telemetry_hub/atak_cot/event.py +181 -0
  12. reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
  13. reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
  14. reticulum_telemetry_hub/config/__init__.py +25 -0
  15. reticulum_telemetry_hub/config/constants.py +7 -0
  16. reticulum_telemetry_hub/config/manager.py +515 -0
  17. reticulum_telemetry_hub/config/models.py +215 -0
  18. reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
  19. reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
  20. reticulum_telemetry_hub/internal_api/__init__.py +21 -0
  21. reticulum_telemetry_hub/internal_api/bus.py +344 -0
  22. reticulum_telemetry_hub/internal_api/core.py +690 -0
  23. reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
  24. reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
  25. reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
  26. reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
  27. reticulum_telemetry_hub/internal_api/versioning.py +63 -0
  28. reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
  29. reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
  30. reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
  31. reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
  32. reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
  33. reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
  34. reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
  35. reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
  36. reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
  37. reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
  38. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
  39. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
  40. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
  41. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
  42. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
  43. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
  44. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
  45. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
  46. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
  47. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
  48. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
  49. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
  50. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
  51. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
  52. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
  53. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
  54. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
  55. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
  56. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
  57. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
  58. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
  59. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
  60. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
  61. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
  62. reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
  63. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
  64. {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
  65. reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
  66. reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
  67. reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
  68. reticulum_telemetry_hub/northbound/__init__.py +5 -0
  69. reticulum_telemetry_hub/northbound/app.py +195 -0
  70. reticulum_telemetry_hub/northbound/auth.py +119 -0
  71. reticulum_telemetry_hub/northbound/gateway.py +310 -0
  72. reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
  73. reticulum_telemetry_hub/northbound/models.py +213 -0
  74. reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
  75. reticulum_telemetry_hub/northbound/routes_files.py +119 -0
  76. reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
  77. reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
  78. reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
  79. reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
  80. reticulum_telemetry_hub/northbound/serializers.py +72 -0
  81. reticulum_telemetry_hub/northbound/services.py +373 -0
  82. reticulum_telemetry_hub/northbound/websocket.py +855 -0
  83. reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
  84. reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
  85. reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
  86. reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
  87. reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
  88. reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
  89. reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
  90. reticulum_telemetry_hub/reticulum_server/services.py +422 -0
  91. reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
  92. reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
  93. {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
  94. reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
  95. lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
  96. lxmf_telemetry/model/persistance/__init__.py +0 -3
  97. lxmf_telemetry/model/persistance/sensors/location.py +0 -69
  98. lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
  99. lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
  100. lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
  101. lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
  102. lxmf_telemetry/telemetry_controller.py +0 -124
  103. reticulum_server/main.py +0 -182
  104. reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
  105. reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
  106. {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
  107. {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
  108. {reticulum_server → reticulum_telemetry_hub/reticulum_server}/__init__.py +0 -0
@@ -0,0 +1,506 @@
1
+ """GeoChat-specific data structures for ATAK Cursor on Target payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import xml.etree.ElementTree as ET
6
+ from dataclasses import dataclass, field
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass
11
+ class ChatHierarchyContact:
12
+ """Represents a contact entry inside a chat hierarchy."""
13
+
14
+ uid: str
15
+ name: str
16
+
17
+ @classmethod
18
+ def from_xml(cls, elem: ET.Element) -> "ChatHierarchyContact":
19
+ """Create a :class:`ChatHierarchyContact` from a ``<contact>`` element."""
20
+
21
+ return cls(uid=elem.get("uid", ""), name=elem.get("name", ""))
22
+
23
+ def to_element(self) -> ET.Element:
24
+ """Return an XML element representing the hierarchy contact."""
25
+
26
+ return ET.Element("contact", {"uid": self.uid, "name": self.name})
27
+
28
+ def to_dict(self) -> dict:
29
+ """Return a serialisable representation of the hierarchy contact."""
30
+
31
+ return {"uid": self.uid, "name": self.name}
32
+
33
+ @classmethod
34
+ def from_dict(cls, data: dict) -> "ChatHierarchyContact":
35
+ """Create a :class:`ChatHierarchyContact` from a dictionary."""
36
+
37
+ return cls(uid=data.get("uid", ""), name=data.get("name", ""))
38
+
39
+
40
+ @dataclass
41
+ class ChatHierarchyGroup:
42
+ """Represents nested groups under the chat hierarchy."""
43
+
44
+ uid: str
45
+ name: str
46
+ contacts: list[ChatHierarchyContact] = field(default_factory=list)
47
+ groups: list["ChatHierarchyGroup"] = field(default_factory=list)
48
+
49
+ @classmethod
50
+ def from_xml(cls, elem: ET.Element) -> "ChatHierarchyGroup":
51
+ """Create a :class:`ChatHierarchyGroup` from a ``<group>`` element."""
52
+
53
+ contacts = [
54
+ ChatHierarchyContact.from_xml(item) for item in elem.findall("contact")
55
+ ]
56
+ child_groups = [
57
+ ChatHierarchyGroup.from_xml(item) for item in elem.findall("group")
58
+ ]
59
+ return cls(
60
+ uid=elem.get("uid", ""),
61
+ name=elem.get("name", ""),
62
+ contacts=contacts,
63
+ groups=child_groups,
64
+ )
65
+
66
+ def to_element(self) -> ET.Element:
67
+ """Return an XML element representing the hierarchy group."""
68
+
69
+ element = ET.Element("group", {"uid": self.uid, "name": self.name})
70
+ for contact in self.contacts:
71
+ element.append(contact.to_element())
72
+ for group in self.groups:
73
+ element.append(group.to_element())
74
+ return element
75
+
76
+ def to_dict(self) -> dict:
77
+ """Return a serialisable representation of the hierarchy group."""
78
+
79
+ data: dict = {"uid": self.uid, "name": self.name}
80
+ if self.contacts:
81
+ data["contacts"] = [contact.to_dict() for contact in self.contacts]
82
+ if self.groups:
83
+ data["groups"] = [group.to_dict() for group in self.groups]
84
+ return data
85
+
86
+ @classmethod
87
+ def from_dict(cls, data: dict) -> "ChatHierarchyGroup":
88
+ """Create a :class:`ChatHierarchyGroup` from a dictionary."""
89
+
90
+ contacts_data = data.get("contacts", [])
91
+ groups_data = data.get("groups", [])
92
+ contacts = [ChatHierarchyContact.from_dict(item) for item in contacts_data]
93
+ groups = [ChatHierarchyGroup.from_dict(item) for item in groups_data]
94
+ return cls(
95
+ uid=data.get("uid", ""),
96
+ name=data.get("name", ""),
97
+ contacts=contacts,
98
+ groups=groups,
99
+ )
100
+
101
+
102
+ @dataclass
103
+ class ChatHierarchy:
104
+ """Root chat hierarchy container."""
105
+
106
+ groups: list[ChatHierarchyGroup] = field(default_factory=list)
107
+
108
+ @classmethod
109
+ def from_xml(cls, elem: ET.Element) -> "ChatHierarchy":
110
+ """Create a :class:`ChatHierarchy` from a ``<hierarchy>`` element."""
111
+
112
+ groups = [ChatHierarchyGroup.from_xml(item) for item in elem.findall("group")]
113
+ return cls(groups=groups)
114
+
115
+ def to_element(self) -> ET.Element:
116
+ """Return an XML hierarchy element."""
117
+
118
+ element = ET.Element("hierarchy")
119
+ for group in self.groups:
120
+ element.append(group.to_element())
121
+ return element
122
+
123
+ def to_dict(self) -> dict:
124
+ """Return a serialisable representation of the hierarchy."""
125
+
126
+ return {"groups": [group.to_dict() for group in self.groups]}
127
+
128
+ @classmethod
129
+ def from_dict(cls, data: dict) -> "ChatHierarchy":
130
+ """Create a :class:`ChatHierarchy` from a dictionary."""
131
+
132
+ groups_data = data.get("groups", [])
133
+ return cls(groups=[ChatHierarchyGroup.from_dict(item) for item in groups_data])
134
+
135
+
136
+ @dataclass
137
+ class ChatGroup:
138
+ """Participants and identifiers for a GeoChat room."""
139
+
140
+ chat_id: str
141
+ uid0: str
142
+ chatroom: Optional[str] = None
143
+ uid1: str = ""
144
+ uid2: str = ""
145
+
146
+ @classmethod
147
+ def from_xml(cls, elem: ET.Element) -> "ChatGroup":
148
+ """Create a :class:`ChatGroup` from a ``<chatgrp>`` element."""
149
+
150
+ return cls(
151
+ chatroom=elem.get("chatroom"),
152
+ chat_id=elem.get("id", ""),
153
+ uid0=elem.get("uid0", ""),
154
+ uid1=elem.get("uid1", ""),
155
+ uid2=elem.get("uid2", ""),
156
+ )
157
+
158
+ def to_element(self) -> ET.Element:
159
+ """Return an XML element describing the chat group."""
160
+
161
+ attrib = {
162
+ "id": self.chat_id,
163
+ "uid0": self.uid0,
164
+ "uid1": self.uid1,
165
+ }
166
+ if self.chatroom:
167
+ attrib["chatroom"] = self.chatroom
168
+ if self.uid2:
169
+ attrib["uid2"] = self.uid2
170
+ return ET.Element("chatgrp", attrib)
171
+
172
+ def to_dict(self) -> dict:
173
+ """Return a serialisable representation of the chat group."""
174
+
175
+ data = {
176
+ "chat_id": self.chat_id,
177
+ "uid0": self.uid0,
178
+ "uid1": self.uid1,
179
+ }
180
+ if self.chatroom:
181
+ data["chatroom"] = self.chatroom
182
+ if self.uid2:
183
+ data["uid2"] = self.uid2
184
+ return data
185
+
186
+ @classmethod
187
+ def from_dict(cls, data: dict) -> "ChatGroup":
188
+ """Create a :class:`ChatGroup` from a dictionary."""
189
+
190
+ return cls(
191
+ chatroom=data.get("chatroom"),
192
+ chat_id=data.get("chat_id", ""),
193
+ uid0=data.get("uid0", ""),
194
+ uid1=data.get("uid1", ""),
195
+ uid2=data.get("uid2", ""),
196
+ )
197
+
198
+
199
+ @dataclass
200
+ class Remarks:
201
+ """Represents annotated remarks content."""
202
+
203
+ text: str
204
+ source: Optional[str] = None
205
+ source_id: Optional[str] = None
206
+ to: Optional[str] = None
207
+ time: Optional[str] = None
208
+
209
+ @classmethod
210
+ def from_xml(cls, elem: ET.Element) -> "Remarks":
211
+ """Create a :class:`Remarks` from a ``<remarks>`` element."""
212
+
213
+ return cls(
214
+ text=elem.text or "",
215
+ source=elem.get("source"),
216
+ source_id=elem.get("sourceID"),
217
+ to=elem.get("to"),
218
+ time=elem.get("time"),
219
+ )
220
+
221
+ def to_element(self) -> ET.Element:
222
+ """Return an XML element with optional metadata."""
223
+
224
+ attrib: dict[str, str] = {}
225
+ if self.source is not None:
226
+ attrib["source"] = self.source
227
+ if self.source_id is not None:
228
+ attrib["sourceID"] = self.source_id
229
+ if self.to is not None:
230
+ attrib["to"] = self.to
231
+ if self.time is not None:
232
+ attrib["time"] = self.time
233
+ element = ET.Element("remarks", attrib)
234
+ element.text = self.text
235
+ return element
236
+
237
+ def to_dict(self) -> dict:
238
+ """Return a serialisable representation of the remarks."""
239
+
240
+ data: dict = {"text": self.text}
241
+ if self.source is not None:
242
+ data["source"] = self.source
243
+ if self.source_id is not None:
244
+ data["source_id"] = self.source_id
245
+ if self.to is not None:
246
+ data["to"] = self.to
247
+ if self.time is not None:
248
+ data["time"] = self.time
249
+ return data
250
+
251
+ @classmethod
252
+ def from_dict(cls, data: dict) -> "Remarks":
253
+ """Create a :class:`Remarks` from a dictionary."""
254
+
255
+ return cls(
256
+ text=data.get("text", ""),
257
+ source=data.get("source"),
258
+ source_id=data.get("source_id"),
259
+ to=data.get("to"),
260
+ time=data.get("time"),
261
+ )
262
+
263
+
264
+ @dataclass
265
+ class MartiDest:
266
+ """Represents a MARTI destination element."""
267
+
268
+ callsign: Optional[str] = None
269
+
270
+ @classmethod
271
+ def from_xml(cls, elem: ET.Element) -> "MartiDest":
272
+ """Create a :class:`MartiDest` from a ``<dest>`` element."""
273
+
274
+ return cls(callsign=elem.get("callsign", ""))
275
+
276
+ def to_element(self) -> ET.Element:
277
+ """Return an XML element representing the destination."""
278
+
279
+ attrib: dict[str, str] = {}
280
+ if self.callsign:
281
+ attrib["callsign"] = self.callsign
282
+ return ET.Element("dest", attrib)
283
+
284
+ def to_dict(self) -> dict:
285
+ """Return a serialisable representation of the destination."""
286
+
287
+ return {"callsign": self.callsign}
288
+
289
+ @classmethod
290
+ def from_dict(cls, data: dict) -> "MartiDest":
291
+ """Create a :class:`MartiDest` from a dictionary."""
292
+
293
+ return cls(callsign=data.get("callsign"))
294
+
295
+
296
+ @dataclass
297
+ class Marti:
298
+ """Represents MARTI routing details."""
299
+
300
+ dest: Optional[MartiDest] = None
301
+
302
+ @classmethod
303
+ def from_xml(cls, elem: ET.Element) -> "Marti":
304
+ """Create a :class:`Marti` from a ``<marti>`` element."""
305
+
306
+ dest_el = elem.find("dest")
307
+ return cls(dest=MartiDest.from_xml(dest_el) if dest_el is not None else None)
308
+
309
+ def to_element(self) -> Optional[ET.Element]:
310
+ """Return a MARTI element when routing information exists."""
311
+
312
+ if self.dest is None:
313
+ return None
314
+ element = ET.Element("marti")
315
+ element.append(self.dest.to_element())
316
+ return element
317
+
318
+ def to_dict(self) -> dict:
319
+ """Return a serialisable representation of the MARTI details."""
320
+
321
+ if self.dest is None:
322
+ return {}
323
+ return {"dest": self.dest.to_dict()}
324
+
325
+ @classmethod
326
+ def from_dict(cls, data: dict) -> "Marti":
327
+ """Create a :class:`Marti` from a dictionary."""
328
+
329
+ dest_data = data.get("dest")
330
+ dest = MartiDest.from_dict(dest_data) if isinstance(dest_data, dict) else None
331
+ return cls(dest=dest)
332
+
333
+
334
+ @dataclass
335
+ class ServerDestination:
336
+ """Represents an empty ``__serverdestination`` marker element."""
337
+
338
+ @staticmethod
339
+ def to_element() -> ET.Element:
340
+ """Return an empty ``__serverdestination`` element."""
341
+
342
+ return ET.Element("__serverdestination")
343
+
344
+ @staticmethod
345
+ def to_dict() -> dict:
346
+ """Return an empty mapping representing the marker."""
347
+
348
+ return {}
349
+
350
+
351
+ @dataclass
352
+ class Chat: # pylint: disable=too-many-instance-attributes
353
+ """Metadata describing the GeoChat parent and room."""
354
+
355
+ parent: Optional[str] = None
356
+ id: Optional[str] = None
357
+ chatroom: Optional[str] = None
358
+ sender_callsign: Optional[str] = None
359
+ group_owner: Optional[str] = None
360
+ message_id: Optional[str] = None
361
+ chat_group: Optional[ChatGroup] = None
362
+ hierarchy: Optional[ChatHierarchy] = None
363
+
364
+ @classmethod
365
+ def from_xml(cls, elem: ET.Element) -> "Chat":
366
+ """Create a :class:`Chat` from an XML ``<__chat>`` element."""
367
+
368
+ chat_group_el = elem.find("chatgrp")
369
+ hierarchy_el = elem.find("hierarchy")
370
+ return cls(
371
+ parent=elem.get("parent"),
372
+ id=elem.get("id"),
373
+ chatroom=elem.get("chatroom"),
374
+ sender_callsign=elem.get("senderCallsign"),
375
+ group_owner=elem.get("groupOwner"),
376
+ message_id=elem.get("messageId"),
377
+ chat_group=(
378
+ ChatGroup.from_xml(chat_group_el) if chat_group_el is not None else None
379
+ ),
380
+ hierarchy=(
381
+ ChatHierarchy.from_xml(hierarchy_el)
382
+ if hierarchy_el is not None
383
+ else None
384
+ ),
385
+ )
386
+
387
+ def to_element(self) -> ET.Element:
388
+ """Return an XML element representing the chat metadata."""
389
+
390
+ attrib = {}
391
+ if self.parent is not None:
392
+ attrib["parent"] = self.parent
393
+ if self.id is not None:
394
+ attrib["id"] = self.id
395
+ if self.chatroom is not None:
396
+ attrib["chatroom"] = self.chatroom
397
+ if self.sender_callsign is not None:
398
+ attrib["senderCallsign"] = self.sender_callsign
399
+ if self.group_owner is not None:
400
+ attrib["groupOwner"] = self.group_owner
401
+ if self.message_id is not None:
402
+ attrib["messageId"] = self.message_id
403
+ element = ET.Element("__chat", attrib)
404
+ if self.chat_group:
405
+ element.append(self.chat_group.to_element())
406
+ if self.hierarchy:
407
+ element.append(self.hierarchy.to_element())
408
+ return element
409
+
410
+ def to_dict(self) -> dict:
411
+ """Return a serialisable representation of the chat details."""
412
+
413
+ data: dict = {}
414
+ if self.parent is not None:
415
+ data["parent"] = self.parent
416
+ if self.id is not None:
417
+ data["id"] = self.id
418
+ if self.chatroom is not None:
419
+ data["chatroom"] = self.chatroom
420
+ if self.sender_callsign is not None:
421
+ data["sender_callsign"] = self.sender_callsign
422
+ if self.group_owner is not None:
423
+ data["group_owner"] = self.group_owner
424
+ if self.message_id is not None:
425
+ data["message_id"] = self.message_id
426
+ if self.chat_group:
427
+ data["chat_group"] = self.chat_group.to_dict()
428
+ if self.hierarchy:
429
+ data["hierarchy"] = self.hierarchy.to_dict()
430
+ return data
431
+
432
+ @classmethod
433
+ def from_dict(cls, data: dict) -> "Chat":
434
+ """Create a :class:`Chat` from a dictionary."""
435
+
436
+ chat_group = None
437
+ if "chat_group" in data:
438
+ chat_group = ChatGroup.from_dict(data["chat_group"])
439
+ hierarchy = None
440
+ if "hierarchy" in data:
441
+ hierarchy = ChatHierarchy.from_dict(data["hierarchy"])
442
+ return cls(
443
+ parent=data.get("parent"),
444
+ id=data.get("id"),
445
+ chatroom=data.get("chatroom"),
446
+ sender_callsign=data.get("sender_callsign"),
447
+ group_owner=data.get("group_owner"),
448
+ message_id=data.get("message_id"),
449
+ chat_group=chat_group,
450
+ hierarchy=hierarchy,
451
+ )
452
+
453
+
454
+ @dataclass
455
+ class Link:
456
+ """Relationship metadata for GeoChat participants."""
457
+
458
+ uid: str
459
+ type: str
460
+ relation: str
461
+ production_time: Optional[str] = None
462
+ parent_callsign: Optional[str] = None
463
+
464
+ @classmethod
465
+ def from_xml(cls, elem: ET.Element) -> "Link":
466
+ """Create a :class:`Link` from a ``<link>`` element."""
467
+
468
+ return cls(
469
+ uid=elem.get("uid", ""),
470
+ type=elem.get("type", ""),
471
+ relation=elem.get("relation", ""),
472
+ production_time=elem.get("production_time"),
473
+ parent_callsign=elem.get("parent_callsign"),
474
+ )
475
+
476
+ def to_element(self) -> ET.Element:
477
+ """Return an XML element for the participant link."""
478
+
479
+ attrib = {"uid": self.uid, "type": self.type, "relation": self.relation}
480
+ if self.production_time is not None:
481
+ attrib["production_time"] = self.production_time
482
+ if self.parent_callsign is not None:
483
+ attrib["parent_callsign"] = self.parent_callsign
484
+ return ET.Element("link", attrib)
485
+
486
+ def to_dict(self) -> dict:
487
+ """Return a serialisable representation of the link."""
488
+
489
+ data = {"uid": self.uid, "type": self.type, "relation": self.relation}
490
+ if self.production_time is not None:
491
+ data["production_time"] = self.production_time
492
+ if self.parent_callsign is not None:
493
+ data["parent_callsign"] = self.parent_callsign
494
+ return data
495
+
496
+ @classmethod
497
+ def from_dict(cls, data: dict) -> "Link":
498
+ """Create a :class:`Link` from a dictionary."""
499
+
500
+ return cls(
501
+ uid=data.get("uid", ""),
502
+ type=data.get("type", ""),
503
+ relation=data.get("relation", ""),
504
+ production_time=data.get("production_time"),
505
+ parent_callsign=data.get("parent_callsign"),
506
+ )