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.
- reticulum_telemetry_hub/api/__init__.py +23 -0
- reticulum_telemetry_hub/api/models.py +323 -0
- reticulum_telemetry_hub/api/service.py +836 -0
- reticulum_telemetry_hub/api/storage.py +528 -0
- reticulum_telemetry_hub/api/storage_base.py +156 -0
- reticulum_telemetry_hub/api/storage_models.py +118 -0
- reticulum_telemetry_hub/atak_cot/__init__.py +49 -0
- reticulum_telemetry_hub/atak_cot/base.py +277 -0
- reticulum_telemetry_hub/atak_cot/chat.py +506 -0
- reticulum_telemetry_hub/atak_cot/detail.py +235 -0
- reticulum_telemetry_hub/atak_cot/event.py +181 -0
- reticulum_telemetry_hub/atak_cot/pytak_client.py +569 -0
- reticulum_telemetry_hub/atak_cot/tak_connector.py +848 -0
- reticulum_telemetry_hub/config/__init__.py +25 -0
- reticulum_telemetry_hub/config/constants.py +7 -0
- reticulum_telemetry_hub/config/manager.py +515 -0
- reticulum_telemetry_hub/config/models.py +215 -0
- reticulum_telemetry_hub/embedded_lxmd/__init__.py +5 -0
- reticulum_telemetry_hub/embedded_lxmd/embedded.py +418 -0
- reticulum_telemetry_hub/internal_api/__init__.py +21 -0
- reticulum_telemetry_hub/internal_api/bus.py +344 -0
- reticulum_telemetry_hub/internal_api/core.py +690 -0
- reticulum_telemetry_hub/internal_api/v1/__init__.py +74 -0
- reticulum_telemetry_hub/internal_api/v1/enums.py +109 -0
- reticulum_telemetry_hub/internal_api/v1/manifest.json +8 -0
- reticulum_telemetry_hub/internal_api/v1/schemas.py +478 -0
- reticulum_telemetry_hub/internal_api/versioning.py +63 -0
- reticulum_telemetry_hub/lxmf_daemon/Handlers.py +122 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMF.py +252 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMPeer.py +898 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMRouter.py +4227 -0
- reticulum_telemetry_hub/lxmf_daemon/LXMessage.py +1006 -0
- reticulum_telemetry_hub/lxmf_daemon/LXStamper.py +490 -0
- reticulum_telemetry_hub/lxmf_daemon/__init__.py +10 -0
- reticulum_telemetry_hub/lxmf_daemon/_version.py +1 -0
- reticulum_telemetry_hub/lxmf_daemon/lxmd.py +1655 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/fields/field_telemetry_stream.py +6 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/__init__.py +3 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/appearance.py +19 -19
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/peer.py +17 -13
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/__init__.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/acceleration.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/ambient_light.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/angular_velocity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/battery.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/connection_map.py +258 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/generic.py +841 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/gravity.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/humidity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/information.py +42 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/location.py +110 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/lxmf_propagation.py +429 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/magnetic_field.py +68 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/physical_link.py +53 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/pressure.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/proximity.py +37 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/received.py +75 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/rns_transport.py +209 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor.py +65 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_enum.py +27 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +58 -0
- reticulum_telemetry_hub/lxmf_telemetry/model/persistance/sensors/temperature.py +37 -0
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/sensors/time.py +36 -32
- {lxmf_telemetry → reticulum_telemetry_hub/lxmf_telemetry}/model/persistance/telemeter.py +26 -23
- reticulum_telemetry_hub/lxmf_telemetry/sampler.py +229 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemeter_manager.py +409 -0
- reticulum_telemetry_hub/lxmf_telemetry/telemetry_controller.py +804 -0
- reticulum_telemetry_hub/northbound/__init__.py +5 -0
- reticulum_telemetry_hub/northbound/app.py +195 -0
- reticulum_telemetry_hub/northbound/auth.py +119 -0
- reticulum_telemetry_hub/northbound/gateway.py +310 -0
- reticulum_telemetry_hub/northbound/internal_adapter.py +302 -0
- reticulum_telemetry_hub/northbound/models.py +213 -0
- reticulum_telemetry_hub/northbound/routes_chat.py +123 -0
- reticulum_telemetry_hub/northbound/routes_files.py +119 -0
- reticulum_telemetry_hub/northbound/routes_rest.py +345 -0
- reticulum_telemetry_hub/northbound/routes_subscribers.py +150 -0
- reticulum_telemetry_hub/northbound/routes_topics.py +178 -0
- reticulum_telemetry_hub/northbound/routes_ws.py +107 -0
- reticulum_telemetry_hub/northbound/serializers.py +72 -0
- reticulum_telemetry_hub/northbound/services.py +373 -0
- reticulum_telemetry_hub/northbound/websocket.py +855 -0
- reticulum_telemetry_hub/reticulum_server/__main__.py +2237 -0
- reticulum_telemetry_hub/reticulum_server/command_manager.py +1268 -0
- reticulum_telemetry_hub/reticulum_server/command_text.py +399 -0
- reticulum_telemetry_hub/reticulum_server/constants.py +1 -0
- reticulum_telemetry_hub/reticulum_server/event_log.py +357 -0
- reticulum_telemetry_hub/reticulum_server/internal_adapter.py +358 -0
- reticulum_telemetry_hub/reticulum_server/outbound_queue.py +312 -0
- reticulum_telemetry_hub/reticulum_server/services.py +422 -0
- reticulumtelemetryhub-0.143.0.dist-info/METADATA +181 -0
- reticulumtelemetryhub-0.143.0.dist-info/RECORD +97 -0
- {reticulumtelemetryhub-0.1.0.dist-info → reticulumtelemetryhub-0.143.0.dist-info}/WHEEL +1 -1
- reticulumtelemetryhub-0.143.0.dist-info/licenses/LICENSE +277 -0
- lxmf_telemetry/model/fields/field_telemetry_stream.py +0 -7
- lxmf_telemetry/model/persistance/__init__.py +0 -3
- lxmf_telemetry/model/persistance/sensors/location.py +0 -69
- lxmf_telemetry/model/persistance/sensors/magnetic_field.py +0 -36
- lxmf_telemetry/model/persistance/sensors/sensor.py +0 -44
- lxmf_telemetry/model/persistance/sensors/sensor_enum.py +0 -24
- lxmf_telemetry/model/persistance/sensors/sensor_mapping.py +0 -9
- lxmf_telemetry/telemetry_controller.py +0 -124
- reticulum_server/main.py +0 -182
- reticulumtelemetryhub-0.1.0.dist-info/METADATA +0 -15
- reticulumtelemetryhub-0.1.0.dist-info/RECORD +0 -19
- {lxmf_telemetry → reticulum_telemetry_hub}/__init__.py +0 -0
- {lxmf_telemetry/model/persistance/sensors → reticulum_telemetry_hub/lxmf_telemetry}/__init__.py +0 -0
- {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()
|