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,74 @@
1
+ """Internal API v1 contract namespace."""
2
+
3
+ from .enums import CommandStatus
4
+ from .enums import CommandType
5
+ from .enums import ErrorCode
6
+ from .enums import EventType
7
+ from .enums import IssuerType
8
+ from .enums import MessageType
9
+ from .enums import NodeType
10
+ from .enums import Qos
11
+ from .enums import QueryType
12
+ from .enums import RetentionPolicy
13
+ from .enums import Severity
14
+ from .enums import SubscriberAction
15
+ from .enums import Visibility
16
+ from .schemas import ApiEnvelopeBase
17
+ from .schemas import CommandEnvelope
18
+ from .schemas import CommandResult
19
+ from .schemas import CreateTopicPayload
20
+ from .schemas import ErrorDetail
21
+ from .schemas import EventEnvelope
22
+ from .schemas import GetNodeStatusPayload
23
+ from .schemas import GetSubscribersPayload
24
+ from .schemas import GetTopicsPayload
25
+ from .schemas import Issuer
26
+ from .schemas import PublishMessagePayload
27
+ from .schemas import QueryCacheHint
28
+ from .schemas import QueryError
29
+ from .schemas import QueryEnvelope
30
+ from .schemas import QueryResult
31
+ from .schemas import QueryResultPayload
32
+ from .schemas import RegisterNodeMetadata
33
+ from .schemas import RegisterNodePayload
34
+ from .schemas import SubscribeTopicPayload
35
+ from .schemas import SUPPORTED_API_VERSION
36
+
37
+ CONTRACT_VERSION = "1.0"
38
+
39
+ __all__ = [
40
+ "ApiEnvelopeBase",
41
+ "CommandEnvelope",
42
+ "CommandResult",
43
+ "CommandStatus",
44
+ "CommandType",
45
+ "CONTRACT_VERSION",
46
+ "CreateTopicPayload",
47
+ "ErrorCode",
48
+ "ErrorDetail",
49
+ "EventEnvelope",
50
+ "EventType",
51
+ "GetNodeStatusPayload",
52
+ "GetSubscribersPayload",
53
+ "GetTopicsPayload",
54
+ "Issuer",
55
+ "IssuerType",
56
+ "MessageType",
57
+ "NodeType",
58
+ "PublishMessagePayload",
59
+ "QueryCacheHint",
60
+ "Qos",
61
+ "QueryError",
62
+ "QueryEnvelope",
63
+ "QueryResult",
64
+ "QueryType",
65
+ "QueryResultPayload",
66
+ "RegisterNodeMetadata",
67
+ "RegisterNodePayload",
68
+ "RetentionPolicy",
69
+ "Severity",
70
+ "SubscriberAction",
71
+ "SubscribeTopicPayload",
72
+ "SUPPORTED_API_VERSION",
73
+ "Visibility",
74
+ ]
@@ -0,0 +1,109 @@
1
+ """Enumerations for the internal API v1 contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class IssuerType(str, Enum):
9
+ """Issuer types for internal API commands."""
10
+
11
+ API = "api"
12
+ RETICULUM = "reticulum"
13
+ INTERNAL = "internal"
14
+
15
+
16
+ class CommandType(str, Enum):
17
+ """Supported command types."""
18
+
19
+ REGISTER_NODE = "RegisterNode"
20
+ CREATE_TOPIC = "CreateTopic"
21
+ SUBSCRIBE_TOPIC = "SubscribeTopic"
22
+ PUBLISH_MESSAGE = "PublishMessage"
23
+
24
+
25
+ class EventType(str, Enum):
26
+ """Supported event types."""
27
+
28
+ NODE_REGISTERED = "NodeRegistered"
29
+ TOPIC_CREATED = "TopicCreated"
30
+ MESSAGE_PUBLISHED = "MessagePublished"
31
+ SUBSCRIBER_UPDATED = "SubscriberUpdated"
32
+
33
+
34
+ class QueryType(str, Enum):
35
+ """Supported query types."""
36
+
37
+ GET_TOPICS = "GetTopics"
38
+ GET_SUBSCRIBERS = "GetSubscribers"
39
+ GET_NODE_STATUS = "GetNodeStatus"
40
+
41
+
42
+ class ErrorCode(str, Enum):
43
+ """Error codes for internal API responses."""
44
+
45
+ API_VERSION_UNSUPPORTED = "API_VERSION_UNSUPPORTED"
46
+ UNAUTHORIZED_COMMAND = "UNAUTHORIZED_COMMAND"
47
+ TOPIC_NOT_FOUND = "TOPIC_NOT_FOUND"
48
+ INVALID_QUERY = "INVALID_QUERY"
49
+ UNAUTHORIZED = "UNAUTHORIZED"
50
+ INTERNAL_ERROR = "INTERNAL_ERROR"
51
+
52
+
53
+ class Severity(str, Enum):
54
+ """Severity levels for errors."""
55
+
56
+ WARNING = "warning"
57
+ ERROR = "error"
58
+ FATAL = "fatal"
59
+
60
+
61
+ class NodeType(str, Enum):
62
+ """Supported node types for RegisterNode."""
63
+
64
+ RETICULUM = "reticulum"
65
+ GATEWAY = "gateway"
66
+ SERVICE = "service"
67
+
68
+
69
+ class RetentionPolicy(str, Enum):
70
+ """Retention policy for topics."""
71
+
72
+ EPHEMERAL = "ephemeral"
73
+ PERSISTENT = "persistent"
74
+
75
+
76
+ class Visibility(str, Enum):
77
+ """Visibility for topics."""
78
+
79
+ PUBLIC = "public"
80
+ RESTRICTED = "restricted"
81
+
82
+
83
+ class MessageType(str, Enum):
84
+ """Message content types."""
85
+
86
+ TELEMETRY = "telemetry"
87
+ EVENT = "event"
88
+ TEXT = "text"
89
+
90
+
91
+ class Qos(str, Enum):
92
+ """Quality of service options."""
93
+
94
+ BEST_EFFORT = "best_effort"
95
+ GUARANTEED = "guaranteed"
96
+
97
+
98
+ class SubscriberAction(str, Enum):
99
+ """Subscriber update actions."""
100
+
101
+ SUBSCRIBED = "subscribed"
102
+ UNSUBSCRIBED = "unsubscribed"
103
+
104
+
105
+ class CommandStatus(str, Enum):
106
+ """Command status values."""
107
+
108
+ ACCEPTED = "accepted"
109
+ REJECTED = "rejected"
@@ -0,0 +1,8 @@
1
+ {
2
+ "contract_version": "1.0",
3
+ "files": {
4
+ "__init__.py": "824987DDE26AA09EB71D6472E38C4A4FACEF4A2A93041A6B538934FE17F09809",
5
+ "enums.py": "1D65B630285628238C3F8FF5B79035959E4E19FD062AF69941B693DEDBFD95D1",
6
+ "schemas.py": "99701050F53DF4396674CE60DF11BFEF6BC6E164E8C99D583E8CA1760F6B4881"
7
+ }
8
+ }
@@ -0,0 +1,478 @@
1
+ """Pydantic schemas for the internal API v1 contract."""
2
+ # pylint: disable=import-error
3
+
4
+ from __future__ import annotations
5
+
6
+ from datetime import datetime
7
+ import re
8
+ from typing import Annotated
9
+ from typing import Dict
10
+ from typing import List
11
+ from typing import Literal
12
+ from typing import Optional
13
+ from typing import Union
14
+ from uuid import UUID
15
+
16
+ from pydantic import BaseModel
17
+ from pydantic import ConfigDict
18
+ from pydantic import Field
19
+ from pydantic import field_validator
20
+ from pydantic import model_validator
21
+ from pydantic_core import PydanticCustomError
22
+
23
+ from .enums import CommandStatus
24
+ from .enums import CommandType
25
+ from .enums import ErrorCode
26
+ from .enums import EventType
27
+ from .enums import IssuerType
28
+ from .enums import MessageType
29
+ from .enums import NodeType
30
+ from .enums import Qos
31
+ from .enums import QueryType
32
+ from .enums import RetentionPolicy
33
+ from .enums import Severity
34
+ from .enums import SubscriberAction
35
+ from .enums import Visibility
36
+
37
+
38
+ SUPPORTED_API_VERSION = "1.0"
39
+
40
+
41
+ def _parse_api_version(value: str) -> tuple[int, int]:
42
+ """Parse and validate an API version string.
43
+
44
+ Args:
45
+ value (str): Version string.
46
+
47
+ Returns:
48
+ tuple[int, int]: Major and minor versions.
49
+ """
50
+
51
+ match = re.fullmatch(r"(\d+)\.(\d+)", value)
52
+ if not match:
53
+ raise PydanticCustomError(
54
+ ErrorCode.API_VERSION_UNSUPPORTED.value,
55
+ "API version unsupported",
56
+ )
57
+ return int(match.group(1)), int(match.group(2))
58
+
59
+
60
+ def _supported_version_parts() -> tuple[int, int]:
61
+ """Return the supported API version parts."""
62
+
63
+ return _parse_api_version(SUPPORTED_API_VERSION)
64
+
65
+
66
+ def _reject_numeric_timestamp(value: object) -> object:
67
+ """Reject numeric timestamps to enforce ISO-8601 strings."""
68
+
69
+ if isinstance(value, (int, float)):
70
+ raise ValueError("timestamp must be ISO-8601")
71
+ return value
72
+
73
+
74
+ class ApiEnvelopeBase(BaseModel):
75
+ """Base class for API envelopes with version enforcement."""
76
+
77
+ model_config = ConfigDict(extra="forbid")
78
+
79
+ api_version: str
80
+
81
+ @model_validator(mode="after")
82
+ def _validate_api_version(self):
83
+ """Validate API version compatibility."""
84
+
85
+ major, minor = _parse_api_version(self.api_version)
86
+ supported_major, supported_minor = _supported_version_parts()
87
+ if major != supported_major:
88
+ raise PydanticCustomError(
89
+ ErrorCode.API_VERSION_UNSUPPORTED.value,
90
+ "API version unsupported",
91
+ )
92
+ if minor < supported_minor:
93
+ raise PydanticCustomError(
94
+ ErrorCode.API_VERSION_UNSUPPORTED.value,
95
+ "API version unsupported",
96
+ )
97
+ return self
98
+
99
+
100
+ class Issuer(BaseModel):
101
+ """Issuer metadata for commands."""
102
+
103
+ model_config = ConfigDict(extra="forbid")
104
+
105
+ type: IssuerType
106
+ id: str
107
+
108
+
109
+ class Location(BaseModel):
110
+ """Location metadata for registered nodes."""
111
+
112
+ model_config = ConfigDict(extra="forbid")
113
+
114
+ lat: Optional[float] = None
115
+ lon: Optional[float] = None
116
+
117
+
118
+ class RegisterNodeMetadata(BaseModel):
119
+ """Metadata for RegisterNode."""
120
+
121
+ model_config = ConfigDict(extra="forbid")
122
+
123
+ name: Optional[str] = Field(default=None, max_length=64)
124
+ description: Optional[str] = Field(default=None, max_length=256)
125
+ capabilities: Optional[List[str]] = None
126
+ location: Optional[Location] = None
127
+
128
+
129
+ class RegisterNodePayload(BaseModel):
130
+ """Payload for RegisterNode."""
131
+
132
+ model_config = ConfigDict(extra="forbid")
133
+
134
+ node_id: str
135
+ node_type: NodeType
136
+ metadata: Optional[RegisterNodeMetadata] = None
137
+
138
+
139
+ class CreateTopicPayload(BaseModel):
140
+ """Payload for CreateTopic."""
141
+
142
+ model_config = ConfigDict(extra="forbid")
143
+
144
+ topic_path: str
145
+ retention: RetentionPolicy
146
+ visibility: Visibility
147
+
148
+
149
+ class SubscribeTopicPayload(BaseModel):
150
+ """Payload for SubscribeTopic."""
151
+
152
+ model_config = ConfigDict(extra="forbid")
153
+
154
+ subscriber_id: str
155
+ topic_path: str
156
+
157
+
158
+ class TextContent(BaseModel):
159
+ """Text message content."""
160
+
161
+ model_config = ConfigDict(extra="forbid")
162
+
163
+ message_type: Literal[MessageType.TEXT] = MessageType.TEXT
164
+ text: str = Field(max_length=4096)
165
+ encoding: Literal["utf-8"] = "utf-8"
166
+
167
+
168
+ class Metric(BaseModel):
169
+ """Telemetry metric."""
170
+
171
+ model_config = ConfigDict(extra="forbid")
172
+
173
+ name: str
174
+ value: Union[float, int, str, bool]
175
+ unit: Optional[str] = None
176
+
177
+
178
+ class TelemetryContent(BaseModel):
179
+ """Telemetry message content."""
180
+
181
+ model_config = ConfigDict(extra="forbid")
182
+
183
+ message_type: Literal[MessageType.TELEMETRY] = MessageType.TELEMETRY
184
+ telemetry_type: Optional[str] = None
185
+ data: Dict[str, object]
186
+ timestamp: Optional[datetime] = None
187
+
188
+
189
+ class EventContent(BaseModel):
190
+ """Event message content."""
191
+
192
+ model_config = ConfigDict(extra="forbid")
193
+
194
+ message_type: Literal[MessageType.EVENT] = MessageType.EVENT
195
+ event_name: str
196
+ attributes: Dict[str, Union[str, float, int, bool]]
197
+
198
+
199
+ MessageContent = Annotated[
200
+ Union[TextContent, TelemetryContent, EventContent],
201
+ Field(discriminator="message_type"),
202
+ ]
203
+
204
+
205
+ class PublishMessagePayload(BaseModel):
206
+ """Payload for PublishMessage."""
207
+
208
+ model_config = ConfigDict(extra="forbid")
209
+
210
+ topic_path: str
211
+ message_type: MessageType
212
+ content: MessageContent
213
+ qos: Qos
214
+
215
+ @model_validator(mode="after")
216
+ def _validate_message_type(self):
217
+ """Ensure message_type matches content."""
218
+
219
+ if self.content.message_type != self.message_type:
220
+ raise PydanticCustomError(
221
+ "MESSAGE_TYPE_MISMATCH",
222
+ "message_type does not match content",
223
+ )
224
+ return self
225
+
226
+
227
+ CommandPayload = Union[
228
+ RegisterNodePayload,
229
+ CreateTopicPayload,
230
+ SubscribeTopicPayload,
231
+ PublishMessagePayload,
232
+ ]
233
+
234
+
235
+ _COMMAND_PAYLOAD_MAP = {
236
+ CommandType.REGISTER_NODE: RegisterNodePayload,
237
+ CommandType.CREATE_TOPIC: CreateTopicPayload,
238
+ CommandType.SUBSCRIBE_TOPIC: SubscribeTopicPayload,
239
+ CommandType.PUBLISH_MESSAGE: PublishMessagePayload,
240
+ }
241
+
242
+
243
+ class CommandEnvelope(ApiEnvelopeBase):
244
+ """Envelope for commands."""
245
+
246
+ command_id: UUID
247
+ command_type: CommandType
248
+ issued_at: datetime
249
+ issuer: Issuer
250
+ payload: CommandPayload
251
+
252
+ @field_validator("issued_at", mode="before")
253
+ @classmethod
254
+ def _validate_issued_at(cls, value: object) -> object:
255
+ return _reject_numeric_timestamp(value)
256
+
257
+ @model_validator(mode="after")
258
+ def _validate_payload(self):
259
+ """Ensure payload type matches command_type."""
260
+
261
+ expected = _COMMAND_PAYLOAD_MAP.get(self.command_type)
262
+ if expected and not isinstance(self.payload, expected):
263
+ raise PydanticCustomError(
264
+ "COMMAND_PAYLOAD_MISMATCH",
265
+ "Payload does not match command_type",
266
+ )
267
+ return self
268
+
269
+
270
+ class NodeRegisteredPayload(BaseModel):
271
+ """Payload for NodeRegistered event."""
272
+
273
+ model_config = ConfigDict(extra="forbid")
274
+
275
+ node_id: str
276
+ node_type: NodeType
277
+
278
+
279
+ class TopicCreatedPayload(BaseModel):
280
+ """Payload for TopicCreated event."""
281
+
282
+ model_config = ConfigDict(extra="forbid")
283
+
284
+ topic_path: str
285
+
286
+
287
+ class MessagePublishedPayload(BaseModel):
288
+ """Payload for MessagePublished event."""
289
+
290
+ model_config = ConfigDict(extra="forbid")
291
+
292
+ topic_path: str
293
+ message_id: str
294
+ originator: str
295
+
296
+
297
+ class SubscriberUpdatedPayload(BaseModel):
298
+ """Payload for SubscriberUpdated event."""
299
+
300
+ model_config = ConfigDict(extra="forbid")
301
+
302
+ subscriber_id: str
303
+ topic_path: str
304
+ action: SubscriberAction
305
+
306
+
307
+ EventPayload = Union[
308
+ NodeRegisteredPayload,
309
+ TopicCreatedPayload,
310
+ MessagePublishedPayload,
311
+ SubscriberUpdatedPayload,
312
+ ]
313
+
314
+
315
+ _EVENT_PAYLOAD_MAP = {
316
+ EventType.NODE_REGISTERED: NodeRegisteredPayload,
317
+ EventType.TOPIC_CREATED: TopicCreatedPayload,
318
+ EventType.MESSAGE_PUBLISHED: MessagePublishedPayload,
319
+ EventType.SUBSCRIBER_UPDATED: SubscriberUpdatedPayload,
320
+ }
321
+
322
+
323
+ class EventEnvelope(ApiEnvelopeBase):
324
+ """Envelope for events."""
325
+
326
+ event_id: UUID
327
+ event_type: EventType
328
+ occurred_at: datetime
329
+ origin: Literal["hub-core"]
330
+ payload: EventPayload
331
+
332
+ @field_validator("occurred_at", mode="before")
333
+ @classmethod
334
+ def _validate_occurred_at(cls, value: object) -> object:
335
+ return _reject_numeric_timestamp(value)
336
+
337
+ @model_validator(mode="after")
338
+ def _validate_payload(self):
339
+ """Ensure payload type matches event_type."""
340
+
341
+ expected = _EVENT_PAYLOAD_MAP.get(self.event_type)
342
+ if expected and not isinstance(self.payload, expected):
343
+ raise PydanticCustomError(
344
+ "EVENT_PAYLOAD_MISMATCH",
345
+ "Payload does not match event_type",
346
+ )
347
+ return self
348
+
349
+
350
+ class GetTopicsPayload(BaseModel):
351
+ """Payload for GetTopics."""
352
+
353
+ model_config = ConfigDict(extra="forbid")
354
+
355
+ prefix: Optional[str] = None
356
+
357
+
358
+ class GetSubscribersPayload(BaseModel):
359
+ """Payload for GetSubscribers."""
360
+
361
+ model_config = ConfigDict(extra="forbid")
362
+
363
+ topic_path: str
364
+
365
+
366
+ class GetNodeStatusPayload(BaseModel):
367
+ """Payload for GetNodeStatus."""
368
+
369
+ model_config = ConfigDict(extra="forbid")
370
+
371
+ node_id: str
372
+
373
+
374
+ QueryPayload = Union[GetTopicsPayload, GetSubscribersPayload, GetNodeStatusPayload]
375
+
376
+
377
+ _QUERY_PAYLOAD_MAP = {
378
+ QueryType.GET_TOPICS: GetTopicsPayload,
379
+ QueryType.GET_SUBSCRIBERS: GetSubscribersPayload,
380
+ QueryType.GET_NODE_STATUS: GetNodeStatusPayload,
381
+ }
382
+
383
+
384
+ class QueryEnvelope(ApiEnvelopeBase):
385
+ """Envelope for queries."""
386
+
387
+ query_id: UUID
388
+ query_type: QueryType
389
+ issued_at: datetime
390
+ payload: QueryPayload
391
+
392
+ @field_validator("issued_at", mode="before")
393
+ @classmethod
394
+ def _validate_issued_at(cls, value: object) -> object:
395
+ return _reject_numeric_timestamp(value)
396
+
397
+ @model_validator(mode="after")
398
+ def _validate_payload(self):
399
+ """Ensure payload type matches query_type."""
400
+
401
+ expected = _QUERY_PAYLOAD_MAP.get(self.query_type)
402
+ if expected and not isinstance(self.payload, expected):
403
+ raise PydanticCustomError(
404
+ "QUERY_PAYLOAD_MISMATCH",
405
+ "Payload does not match query_type",
406
+ )
407
+ return self
408
+
409
+
410
+ class CommandResult(BaseModel):
411
+ """Result for command execution."""
412
+
413
+ model_config = ConfigDict(extra="forbid")
414
+
415
+ command_id: UUID
416
+ status: CommandStatus
417
+ reason: Optional[str] = None
418
+
419
+
420
+ class QueryResult(BaseModel):
421
+ """Result for query execution."""
422
+
423
+ model_config = ConfigDict(extra="forbid")
424
+
425
+ query_id: UUID
426
+ ok: bool
427
+ result: Optional["QueryResultPayload"] = None
428
+ error: Optional["QueryError"] = None
429
+
430
+ @model_validator(mode="after")
431
+ def _validate_outcome(self):
432
+ """Ensure ok/error/result coherence."""
433
+
434
+ if self.ok:
435
+ if self.error is not None or self.result is None:
436
+ raise ValueError("ok results must include result and no error")
437
+ else:
438
+ if self.error is None:
439
+ raise ValueError("error results must include error")
440
+ return self
441
+
442
+
443
+ class QueryCacheHint(BaseModel):
444
+ """Cache hints for query results."""
445
+
446
+ model_config = ConfigDict(extra="forbid")
447
+
448
+ ttl_seconds: int
449
+ scope: Literal["node", "hub", "network"]
450
+ stale_while_revalidate: bool
451
+
452
+
453
+ class QueryResultPayload(BaseModel):
454
+ """Query result payload with optional cache hints."""
455
+
456
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
457
+
458
+ data: Dict[str, object]
459
+ cache: Optional[QueryCacheHint] = Field(default=None, alias="_cache")
460
+
461
+
462
+ class QueryError(BaseModel):
463
+ """Query error payload."""
464
+
465
+ model_config = ConfigDict(extra="forbid")
466
+
467
+ code: ErrorCode
468
+ message: str
469
+
470
+
471
+ class ErrorDetail(BaseModel):
472
+ """Error payload for command/query failures."""
473
+
474
+ model_config = ConfigDict(extra="forbid")
475
+
476
+ error_code: ErrorCode
477
+ severity: Severity
478
+ message: str