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,302 @@
1
+ """Internal API adapter routes for the northbound gateway."""
2
+ # pylint: disable=import-error
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import logging
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from datetime import timezone
11
+ from typing import Optional
12
+ from uuid import uuid4
13
+
14
+ from fastapi import APIRouter
15
+ from fastapi import FastAPI
16
+ from fastapi import HTTPException
17
+ from fastapi import Query
18
+ from fastapi import WebSocket
19
+ from fastapi import WebSocketDisconnect
20
+ from fastapi import status
21
+ from pydantic import BaseModel
22
+ from pydantic import ConfigDict
23
+
24
+ from reticulum_telemetry_hub.internal_api import InProcessCommandBus
25
+ from reticulum_telemetry_hub.internal_api import InProcessEventBus
26
+ from reticulum_telemetry_hub.internal_api import InProcessQueryBus
27
+ from reticulum_telemetry_hub.internal_api import InternalApiCore
28
+ from reticulum_telemetry_hub.internal_api.bus import CommandBus
29
+ from reticulum_telemetry_hub.internal_api.bus import EventBus
30
+ from reticulum_telemetry_hub.internal_api.bus import QueryBus
31
+ from reticulum_telemetry_hub.internal_api.v1.enums import CommandStatus
32
+ from reticulum_telemetry_hub.internal_api.v1.enums import CommandType
33
+ from reticulum_telemetry_hub.internal_api.v1.enums import ErrorCode
34
+ from reticulum_telemetry_hub.internal_api.v1.enums import IssuerType
35
+ from reticulum_telemetry_hub.internal_api.v1.enums import MessageType
36
+ from reticulum_telemetry_hub.internal_api.v1.enums import Qos
37
+ from reticulum_telemetry_hub.internal_api.v1.enums import QueryType
38
+ from reticulum_telemetry_hub.internal_api.v1.schemas import CommandEnvelope
39
+ from reticulum_telemetry_hub.internal_api.v1.schemas import CommandResult
40
+ from reticulum_telemetry_hub.internal_api.v1.schemas import QueryEnvelope
41
+ from reticulum_telemetry_hub.internal_api.v1.schemas import QueryResult
42
+ from reticulum_telemetry_hub.internal_api.versioning import select_api_version
43
+
44
+
45
+ _ISSUER_ID = "internal-adapter"
46
+ _LOGGER = logging.getLogger(__name__)
47
+
48
+
49
+ class InternalMessageRequest(BaseModel):
50
+ """Request payload for sending a text message."""
51
+
52
+ model_config = ConfigDict(extra="forbid")
53
+
54
+ destination: str
55
+ text: str
56
+
57
+
58
+ @dataclass
59
+ class InternalAdapter:
60
+ """Container for internal API adapter dependencies."""
61
+
62
+ command_bus: CommandBus
63
+ query_bus: QueryBus
64
+ event_bus: EventBus
65
+ core: InternalApiCore
66
+
67
+ async def start(self) -> None:
68
+ """Start in-process buses."""
69
+
70
+ await self.event_bus.start()
71
+ await self.command_bus.start()
72
+ await self.query_bus.start()
73
+
74
+ async def stop(self) -> None:
75
+ """Stop in-process buses."""
76
+
77
+ await self.command_bus.stop()
78
+ await self.query_bus.stop()
79
+ await self.event_bus.stop()
80
+
81
+
82
+ def build_internal_adapter() -> InternalAdapter:
83
+ """Build an internal adapter backed by in-process buses."""
84
+
85
+ event_bus = InProcessEventBus()
86
+ core = InternalApiCore(event_bus)
87
+ command_bus = InProcessCommandBus()
88
+ query_bus = InProcessQueryBus()
89
+ command_bus.register_handler(core.handle_command)
90
+ query_bus.register_handler(core.handle_query)
91
+ return InternalAdapter(
92
+ command_bus=command_bus,
93
+ query_bus=query_bus,
94
+ event_bus=event_bus,
95
+ core=core,
96
+ )
97
+
98
+
99
+ def register_internal_adapter(app: FastAPI, *, adapter: InternalAdapter) -> None:
100
+ """Register internal adapter routes on the FastAPI app."""
101
+
102
+ router = APIRouter(prefix="/internal")
103
+
104
+ @app.on_event("startup")
105
+ async def _start_internal_buses() -> None:
106
+ await adapter.start()
107
+
108
+ @app.on_event("shutdown")
109
+ async def _stop_internal_buses() -> None:
110
+ await adapter.stop()
111
+
112
+ @router.get("/topics")
113
+ async def list_topics(prefix: Optional[str] = Query(default=None)) -> list[str]:
114
+ """Return topic identifiers using the internal query API."""
115
+
116
+ query = _build_query(QueryType.GET_TOPICS, {"prefix": prefix} if prefix else {})
117
+ result = await adapter.query_bus.execute(query)
118
+ data = _unwrap_query(result)
119
+ return [topic["topic_id"] for topic in data.get("topics", [])]
120
+
121
+ @router.get("/topics/{topic_id}/subscribers")
122
+ async def list_subscribers(topic_id: str) -> list[str]:
123
+ """Return subscriber node IDs for a topic."""
124
+
125
+ query = _build_query(QueryType.GET_SUBSCRIBERS, {"topic_path": topic_id})
126
+ result = await adapter.query_bus.execute(query)
127
+ data = _unwrap_query(result)
128
+ return [entry["node_id"] for entry in data.get("subscribers", [])]
129
+
130
+ @router.get("/nodes/{node_id}")
131
+ async def get_node_status(node_id: str) -> dict:
132
+ """Return collapsed node status details."""
133
+
134
+ query = _build_query(QueryType.GET_NODE_STATUS, {"node_id": node_id})
135
+ result = await adapter.query_bus.execute(query)
136
+ data = _unwrap_query(result)
137
+ status_value = data.get("status")
138
+ return {
139
+ "node_id": data.get("node_id", node_id),
140
+ "online": status_value == "online",
141
+ "topics": data.get("topics", []),
142
+ }
143
+
144
+ @router.post("/message")
145
+ async def post_message(payload: InternalMessageRequest) -> dict:
146
+ """Publish a text message using the internal command API."""
147
+
148
+ command = _build_command(
149
+ CommandType.PUBLISH_MESSAGE,
150
+ {
151
+ "topic_path": payload.destination,
152
+ "message_type": MessageType.TEXT,
153
+ "content": {
154
+ "message_type": MessageType.TEXT,
155
+ "text": payload.text,
156
+ "encoding": "utf-8",
157
+ },
158
+ "qos": Qos.BEST_EFFORT,
159
+ },
160
+ )
161
+ result = await adapter.command_bus.send(command)
162
+ _raise_for_command(result)
163
+ return {"accepted": True}
164
+
165
+ @router.websocket("/events/stream")
166
+ async def stream_events(websocket: WebSocket) -> None:
167
+ """Stream internal API events over WebSocket."""
168
+
169
+ await websocket.accept()
170
+ queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=32)
171
+
172
+ async def _handler(event) -> None:
173
+ await queue.put(event.model_dump(mode="json"))
174
+
175
+ unsubscribe = adapter.event_bus.subscribe(_handler)
176
+ try:
177
+ while True:
178
+ message = await queue.get()
179
+ await websocket.send_json(message)
180
+ except WebSocketDisconnect:
181
+ return
182
+ finally:
183
+ unsubscribe()
184
+
185
+ app.include_router(router)
186
+ app.state.internal_adapter = adapter
187
+
188
+
189
+ def _build_command(command_type: CommandType, payload: dict) -> CommandEnvelope:
190
+ """Build a command envelope with API issuer metadata."""
191
+
192
+ command = CommandEnvelope.model_validate(
193
+ {
194
+ "api_version": select_api_version(),
195
+ "command_id": uuid4(),
196
+ "command_type": command_type,
197
+ "issued_at": _utc_now(),
198
+ "issuer": {"type": IssuerType.API, "id": _ISSUER_ID},
199
+ "payload": payload,
200
+ }
201
+ )
202
+ _LOGGER.debug(
203
+ "Internal adapter command built",
204
+ extra={
205
+ "command_id": str(command.command_id),
206
+ "command_type": command.command_type.value,
207
+ "correlation_id": str(command.command_id),
208
+ },
209
+ )
210
+ return command
211
+
212
+
213
+ def _build_query(query_type: QueryType, payload: dict) -> QueryEnvelope:
214
+ """Build a query envelope."""
215
+
216
+ query = QueryEnvelope.model_validate(
217
+ {
218
+ "api_version": select_api_version(),
219
+ "query_id": uuid4(),
220
+ "query_type": query_type,
221
+ "issued_at": _utc_now(),
222
+ "payload": payload,
223
+ }
224
+ )
225
+ _LOGGER.debug(
226
+ "Internal adapter query built",
227
+ extra={
228
+ "query_id": str(query.query_id),
229
+ "query_type": query.query_type.value,
230
+ "correlation_id": str(query.query_id),
231
+ },
232
+ )
233
+ return query
234
+
235
+
236
+ def _unwrap_query(result: QueryResult) -> dict:
237
+ """Return query data or raise an HTTP error."""
238
+
239
+ if result.ok and result.result is not None:
240
+ return result.result.data
241
+ error = result.error
242
+ if error is None:
243
+ raise HTTPException(
244
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
245
+ detail="Query failed without an error payload",
246
+ )
247
+ status_code = _map_query_error(error.code)
248
+ raise HTTPException(
249
+ status_code=status_code,
250
+ detail={"code": error.code.value, "message": error.message},
251
+ )
252
+
253
+
254
+ def _raise_for_command(result: CommandResult) -> None:
255
+ """Raise HTTP errors for rejected commands."""
256
+
257
+ if result.status == CommandStatus.ACCEPTED:
258
+ return
259
+ if result.reason:
260
+ status_code = _map_command_error(result.reason)
261
+ raise HTTPException(
262
+ status_code=status_code,
263
+ detail={"code": result.reason, "message": "Command rejected"},
264
+ )
265
+ raise HTTPException(
266
+ status_code=status.HTTP_400_BAD_REQUEST,
267
+ detail={"code": "COMMAND_REJECTED", "message": "Command rejected"},
268
+ )
269
+
270
+
271
+ def _map_query_error(error_code: ErrorCode) -> int:
272
+ """Map query error codes to HTTP status codes."""
273
+
274
+ if error_code == ErrorCode.TOPIC_NOT_FOUND:
275
+ return status.HTTP_404_NOT_FOUND
276
+ if error_code == ErrorCode.INVALID_QUERY:
277
+ return status.HTTP_400_BAD_REQUEST
278
+ if error_code == ErrorCode.UNAUTHORIZED:
279
+ return status.HTTP_403_FORBIDDEN
280
+ if error_code == ErrorCode.INTERNAL_ERROR:
281
+ return status.HTTP_500_INTERNAL_SERVER_ERROR
282
+ return status.HTTP_500_INTERNAL_SERVER_ERROR
283
+
284
+
285
+ def _map_command_error(reason: str) -> int:
286
+ """Map command rejection reasons to HTTP status codes."""
287
+
288
+ try:
289
+ error_code = ErrorCode(reason)
290
+ except ValueError:
291
+ return status.HTTP_400_BAD_REQUEST
292
+ if error_code == ErrorCode.TOPIC_NOT_FOUND:
293
+ return status.HTTP_404_NOT_FOUND
294
+ if error_code == ErrorCode.UNAUTHORIZED_COMMAND:
295
+ return status.HTTP_403_FORBIDDEN
296
+ return status.HTTP_400_BAD_REQUEST
297
+
298
+
299
+ def _utc_now() -> datetime:
300
+ """Return the current UTC timestamp."""
301
+
302
+ return datetime.now(timezone.utc)
@@ -0,0 +1,213 @@
1
+ """Pydantic models for the northbound API."""
2
+ # pylint: disable=import-error
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+ from typing import Dict
8
+ from typing import Optional
9
+
10
+ from pydantic import AliasChoices
11
+ from pydantic import BaseModel
12
+ from pydantic import ConfigDict
13
+ from pydantic import Field
14
+ from pydantic import model_validator
15
+
16
+
17
+ def _normalize_aliases(values: object, alias_map: dict[str, tuple[str, ...]]) -> object:
18
+ """Normalize payload keys using alias hints.
19
+
20
+ Args:
21
+ values (object): Raw payload input.
22
+ alias_map (dict[str, tuple[str, ...]]): Map of canonical keys to alias keys.
23
+
24
+ Returns:
25
+ object: Normalized payload values.
26
+ """
27
+
28
+ if not isinstance(values, dict):
29
+ return values
30
+
31
+ normalized = dict(values)
32
+ for field_name, aliases in alias_map.items():
33
+ if field_name in normalized:
34
+ continue
35
+ for alias in aliases:
36
+ if alias in normalized:
37
+ normalized[field_name] = normalized[alias]
38
+ break
39
+ return normalized
40
+
41
+
42
+ class TopicPayload(BaseModel):
43
+ """Topic payload for create/update requests."""
44
+
45
+ model_config = ConfigDict(populate_by_name=True)
46
+
47
+ topic_id: Optional[str] = Field(
48
+ default=None,
49
+ validation_alias=AliasChoices("TopicID", "topic_id", "topicId", "id"),
50
+ serialization_alias="TopicID",
51
+ )
52
+ topic_name: Optional[str] = Field(
53
+ default=None,
54
+ validation_alias=AliasChoices("TopicName", "topic_name", "topicName", "name"),
55
+ serialization_alias="TopicName",
56
+ )
57
+ topic_path: Optional[str] = Field(
58
+ default=None,
59
+ validation_alias=AliasChoices("TopicPath", "topic_path", "topicPath", "path"),
60
+ serialization_alias="TopicPath",
61
+ )
62
+ topic_description: Optional[str] = Field(
63
+ default=None,
64
+ validation_alias=AliasChoices("TopicDescription", "topic_description", "topicDescription", "description"),
65
+ serialization_alias="TopicDescription",
66
+ )
67
+
68
+
69
+ class SubscriberPayload(BaseModel):
70
+ """Subscriber payload for create/update requests."""
71
+
72
+ model_config = ConfigDict(populate_by_name=True)
73
+
74
+ subscriber_id: Optional[str] = Field(
75
+ default=None,
76
+ validation_alias=AliasChoices("SubscriberID", "subscriber_id", "subscriberId", "id"),
77
+ serialization_alias="SubscriberID",
78
+ )
79
+ destination: Optional[str] = Field(
80
+ default=None,
81
+ validation_alias=AliasChoices("Destination", "destination"),
82
+ serialization_alias="Destination",
83
+ )
84
+ topic_id: Optional[str] = Field(
85
+ default=None,
86
+ validation_alias=AliasChoices("TopicID", "topic_id", "topicId"),
87
+ serialization_alias="TopicID",
88
+ )
89
+ reject_tests: Optional[int] = Field(
90
+ default=None,
91
+ validation_alias=AliasChoices("RejectTests", "reject_tests", "rejectTests"),
92
+ serialization_alias="RejectTests",
93
+ )
94
+ metadata: Optional[Dict[str, Any]] = Field(
95
+ default=None,
96
+ validation_alias=AliasChoices("Metadata", "metadata"),
97
+ serialization_alias="Metadata",
98
+ )
99
+
100
+
101
+ class SubscribeTopicRequest(BaseModel):
102
+ """Payload for topic subscription requests."""
103
+
104
+ model_config = ConfigDict(populate_by_name=True)
105
+
106
+ topic_id: str = Field(
107
+ validation_alias=AliasChoices("TopicID", "topic_id", "topicId", "id"),
108
+ serialization_alias="TopicID",
109
+ )
110
+ destination: Optional[str] = Field(
111
+ default=None,
112
+ validation_alias=AliasChoices("Destination", "destination"),
113
+ serialization_alias="Destination",
114
+ )
115
+ reject_tests: Optional[int] = Field(
116
+ default=None,
117
+ validation_alias=AliasChoices("RejectTests", "reject_tests", "rejectTests"),
118
+ serialization_alias="RejectTests",
119
+ )
120
+ metadata: Optional[Dict[str, Any]] = Field(
121
+ default=None,
122
+ validation_alias=AliasChoices("Metadata", "metadata"),
123
+ serialization_alias="Metadata",
124
+ )
125
+
126
+
127
+ class ConfigRollbackPayload(BaseModel):
128
+ """Payload for configuration rollbacks."""
129
+
130
+ backup_path: Optional[str] = None
131
+
132
+
133
+ class MessagePayload(BaseModel):
134
+ """Payload for sending chat messages into the hub."""
135
+
136
+ model_config = ConfigDict(populate_by_name=True)
137
+
138
+ content: str
139
+ topic_id: Optional[str] = None
140
+ destination: Optional[str] = None
141
+
142
+ @model_validator(mode="before")
143
+ @classmethod
144
+ def _normalize_payload(cls, values: object) -> object:
145
+ """Normalize payload aliases to field names.
146
+
147
+ Args:
148
+ values (object): Raw payload input.
149
+
150
+ Returns:
151
+ object: Normalized payload values.
152
+ """
153
+
154
+ return _normalize_aliases(
155
+ values,
156
+ {
157
+ "content": ("Content",),
158
+ "topic_id": ("TopicID", "topicId"),
159
+ "destination": ("Destination",),
160
+ },
161
+ )
162
+
163
+
164
+ class ChatSendPayload(BaseModel):
165
+ """Payload for sending chat messages with optional attachments."""
166
+
167
+ model_config = ConfigDict(populate_by_name=True)
168
+
169
+ content: Optional[str] = None
170
+ scope: str
171
+ topic_id: Optional[str] = None
172
+ destination: Optional[str] = None
173
+ file_ids: list[int] = Field(default_factory=list)
174
+ image_ids: list[int] = Field(default_factory=list)
175
+
176
+ @model_validator(mode="before")
177
+ @classmethod
178
+ def _normalize_payload(cls, values: object) -> object:
179
+ """Normalize payload aliases to field names.
180
+
181
+ Args:
182
+ values (object): Raw payload input.
183
+
184
+ Returns:
185
+ object: Normalized payload values.
186
+ """
187
+
188
+ return _normalize_aliases(
189
+ values,
190
+ {
191
+ "content": ("Content",),
192
+ "scope": ("Scope",),
193
+ "topic_id": ("TopicID", "topicId"),
194
+ "destination": ("Destination",),
195
+ "file_ids": ("FileIDs", "fileIds"),
196
+ "image_ids": ("ImageIDs", "imageIds"),
197
+ },
198
+ )
199
+
200
+ @model_validator(mode="after")
201
+ def _validate_payload(self) -> "ChatSendPayload":
202
+ """Validate scope-specific requirements."""
203
+
204
+ scope = self.scope.lower().strip()
205
+ if scope not in {"dm", "topic", "broadcast"}:
206
+ raise ValueError("Scope must be dm, topic, or broadcast")
207
+ if scope == "dm" and not self.destination:
208
+ raise ValueError("Destination is required for DM scope")
209
+ if scope == "topic" and not self.topic_id:
210
+ raise ValueError("TopicID is required for topic scope")
211
+ if not (self.content and self.content.strip()) and not (self.file_ids or self.image_ids):
212
+ raise ValueError("Content or attachments are required")
213
+ return self
@@ -0,0 +1,123 @@
1
+ """Chat routes for the northbound API."""
2
+ # pylint: disable=import-error
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ from typing import Optional
8
+
9
+ from fastapi import Depends
10
+ from fastapi import FastAPI
11
+ from fastapi import File
12
+ from fastapi import Form
13
+ from fastapi import HTTPException
14
+ from fastapi import Query
15
+ from fastapi import UploadFile
16
+ from fastapi import status
17
+
18
+ from .models import ChatSendPayload
19
+ from .services import NorthboundServices
20
+
21
+
22
+ MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024
23
+
24
+
25
+ def register_chat_routes(
26
+ app: FastAPI,
27
+ *,
28
+ services: NorthboundServices,
29
+ require_protected,
30
+ ) -> None:
31
+ """Register chat routes on the FastAPI app."""
32
+
33
+ @app.get("/Chat/Messages", dependencies=[Depends(require_protected)])
34
+ def list_chat_messages(
35
+ limit: int = Query(default=200),
36
+ direction: Optional[str] = Query(default=None),
37
+ topic_id: Optional[str] = Query(default=None, alias="topic_id"),
38
+ destination: Optional[str] = Query(default=None),
39
+ source: Optional[str] = Query(default=None),
40
+ ) -> list[dict]:
41
+ """Return persisted chat messages."""
42
+
43
+ messages = services.list_chat_messages(
44
+ limit=limit,
45
+ direction=direction,
46
+ topic_id=topic_id,
47
+ destination=destination,
48
+ source=source,
49
+ )
50
+ return [message.to_dict() for message in messages]
51
+
52
+ @app.post("/Chat/Message", dependencies=[Depends(require_protected)])
53
+ def send_chat_message(payload: ChatSendPayload) -> dict:
54
+ """Send a chat message with optional attachments."""
55
+
56
+ try:
57
+ attachments = services.resolve_attachments(
58
+ file_ids=payload.file_ids, image_ids=payload.image_ids
59
+ )
60
+ except KeyError as exc:
61
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
62
+ try:
63
+ message = services.send_chat_message(
64
+ content=payload.content or "",
65
+ scope=payload.scope,
66
+ topic_id=payload.topic_id,
67
+ destination=payload.destination,
68
+ attachments=attachments,
69
+ )
70
+ except RuntimeError as exc:
71
+ raise HTTPException(
72
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
73
+ ) from exc
74
+ return message.to_dict()
75
+
76
+ @app.post("/Chat/Attachment", dependencies=[Depends(require_protected)])
77
+ async def upload_chat_attachment(
78
+ category: str = Form(...),
79
+ file: UploadFile = File(...),
80
+ sha256: Optional[str] = Form(default=None),
81
+ topic_id: Optional[str] = Form(default=None),
82
+ ) -> dict:
83
+ """Upload a chat attachment to the hub."""
84
+
85
+ normalized = category.lower().strip()
86
+ if normalized not in {"file", "image"}:
87
+ raise HTTPException(
88
+ status_code=status.HTTP_400_BAD_REQUEST,
89
+ detail="Attachment category must be file or image",
90
+ )
91
+ content = await file.read()
92
+ if not content:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_400_BAD_REQUEST,
95
+ detail="Attachment content is empty",
96
+ )
97
+ if len(content) > MAX_ATTACHMENT_BYTES:
98
+ raise HTTPException(
99
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
100
+ detail="Attachment exceeds size limit",
101
+ )
102
+ if normalized == "image":
103
+ media_type = file.content_type or ""
104
+ if not media_type.startswith("image/"):
105
+ raise HTTPException(
106
+ status_code=status.HTTP_400_BAD_REQUEST,
107
+ detail="Image attachments must use an image content type",
108
+ )
109
+ if sha256:
110
+ digest = hashlib.sha256(content).hexdigest()
111
+ if digest.lower() != sha256.lower():
112
+ raise HTTPException(
113
+ status_code=status.HTTP_400_BAD_REQUEST,
114
+ detail="Attachment hash mismatch",
115
+ )
116
+ attachment = services.store_uploaded_attachment(
117
+ content=content,
118
+ filename=file.filename or "upload.bin",
119
+ media_type=file.content_type,
120
+ category=normalized,
121
+ topic_id=topic_id,
122
+ )
123
+ return attachment.to_dict()