topicgate 1.1.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.
- topicgate/__init__.py +0 -0
- topicgate/__main__.py +5 -0
- topicgate/app/__init__.py +0 -0
- topicgate/app/app_dependencies.py +148 -0
- topicgate/app/broker_runtime_state.py +43 -0
- topicgate/app/models/__init__.py +1 -0
- topicgate/app/models/broker_snapshot.py +107 -0
- topicgate/app/models/mcp_setup.py +20 -0
- topicgate/app/services/__init__.py +0 -0
- topicgate/app/services/broker_profile_service.py +245 -0
- topicgate/app/services/broker_resolver.py +58 -0
- topicgate/app/services/broker_snapshot_service.py +403 -0
- topicgate/app/services/control_operation_service.py +158 -0
- topicgate/app/services/mcp_setup_service.py +192 -0
- topicgate/app/services/observation_cache_service.py +256 -0
- topicgate/app/services/observation_retention_policy_service.py +33 -0
- topicgate/app/services/persistence_lifecycle.py +25 -0
- topicgate/app/services/service_container.py +20 -0
- topicgate/app/services/service_item.py +13 -0
- topicgate/app/topicgate_runtime.py +496 -0
- topicgate/assets/icon.png +0 -0
- topicgate/assets/icon.svg +37 -0
- topicgate/core/__init__.py +0 -0
- topicgate/core/config/__init__.py +0 -0
- topicgate/core/config/app_config.py +7 -0
- topicgate/core/config/mqtt_config.py +10 -0
- topicgate/core/interfaces/__init__.py +0 -0
- topicgate/core/interfaces/broker_profile_store.py +26 -0
- topicgate/core/interfaces/mqtt_message_processor.py +12 -0
- topicgate/core/interfaces/observer_repository.py +82 -0
- topicgate/core/interfaces/topic_message_store.py +13 -0
- topicgate/core/models/__init__.py +0 -0
- topicgate/core/models/broker_profile.py +15 -0
- topicgate/core/models/broker_profile_identity.py +14 -0
- topicgate/core/models/broker_summary.py +14 -0
- topicgate/core/models/connection_status.py +9 -0
- topicgate/core/models/message_filter.py +9 -0
- topicgate/core/models/mqtt_message.py +13 -0
- topicgate/core/models/mqtt_observation.py +36 -0
- topicgate/core/models/mqtt_state.py +5 -0
- topicgate/core/models/observation_cache_administration.py +135 -0
- topicgate/core/models/observation_deletion_preview.py +39 -0
- topicgate/core/models/observation_retention_policy.py +65 -0
- topicgate/core/models/observer_model.py +22 -0
- topicgate/core/models/observer_workspace.py +13 -0
- topicgate/core/models/subscription.py +29 -0
- topicgate/core/models/topic_message.py +16 -0
- topicgate/core/mqtt_topics.py +46 -0
- topicgate/core/observer_limits.py +8 -0
- topicgate/core/payload_limits.py +6 -0
- topicgate/gui/__init__.py +0 -0
- topicgate/gui/app.py +97 -0
- topicgate/gui/broker_state_reader.py +29 -0
- topicgate/gui/components/about_dialog.py +100 -0
- topicgate/gui/components/add_subscription_dialog.py +59 -0
- topicgate/gui/components/application_header.py +137 -0
- topicgate/gui/components/broker_settings_dialog.py +223 -0
- topicgate/gui/components/connection_controls.py +123 -0
- topicgate/gui/components/connection_status.py +7 -0
- topicgate/gui/components/log_console.py +26 -0
- topicgate/gui/components/mcp_setup_dialog.py +198 -0
- topicgate/gui/components/observer_tree.py +434 -0
- topicgate/gui/components/onboarding_panel.py +74 -0
- topicgate/gui/components/publish_pane.py +60 -0
- topicgate/gui/components/snapshot_panel.py +343 -0
- topicgate/gui/components/stored_observations_dialog.py +611 -0
- topicgate/gui/components/subscription_settings.py +151 -0
- topicgate/gui/components/topic_details.py +51 -0
- topicgate/gui/components/topic_metadata.py +79 -0
- topicgate/gui/components/workspace_pane.py +30 -0
- topicgate/gui/gui.py +5 -0
- topicgate/gui/main_view_model.py +814 -0
- topicgate/gui/main_window.py +986 -0
- topicgate/gui/observer_state_reader.py +47 -0
- topicgate/gui/settings_migration.py +32 -0
- topicgate/gui/theme.py +61 -0
- topicgate/infrastructure/__init__.py +0 -0
- topicgate/infrastructure/credentials/__init__.py +1 -0
- topicgate/infrastructure/credentials/credential_store.py +10 -0
- topicgate/infrastructure/credentials/os_credential_store.py +20 -0
- topicgate/infrastructure/database/__init__.py +0 -0
- topicgate/infrastructure/database/base.py +5 -0
- topicgate/infrastructure/database/database_context.py +56 -0
- topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
- topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
- topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
- topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
- topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
- topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
- topicgate/infrastructure/database/migrations.py +57 -0
- topicgate/infrastructure/database/models/__init__.py +25 -0
- topicgate/infrastructure/database/models/app_config_row.py +18 -0
- topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
- topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
- topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
- topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
- topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
- topicgate/infrastructure/database/models/subscription_row.py +18 -0
- topicgate/infrastructure/mqtt/__init__.py +0 -0
- topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
- topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
- topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
- topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
- topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
- topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
- topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
- topicgate/infrastructure/repository/__init__.py +0 -0
- topicgate/infrastructure/repository/broker_config_repository.py +68 -0
- topicgate/infrastructure/repository/broker_repository.py +170 -0
- topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
- topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
- topicgate/infrastructure/repository/subscription_repository.py +117 -0
- topicgate/infrastructure/repository/topic_message_repository.py +438 -0
- topicgate/mcp/__init__.py +0 -0
- topicgate/mcp/api/broker_api.py +48 -0
- topicgate/mcp/api/connection_api.py +83 -0
- topicgate/mcp/api/dashboard_api.py +790 -0
- topicgate/mcp/api/dashboard_snapshot.py +278 -0
- topicgate/mcp/api/mcp_api.py +18 -0
- topicgate/mcp/api/preview/dashboard_preview.py +6 -0
- topicgate/mcp/api/publish_api.py +62 -0
- topicgate/mcp/api/snapshot_api.py +78 -0
- topicgate/mcp/api/subscription_api.py +134 -0
- topicgate/mcp/api/topic_api.py +77 -0
- topicgate/mcp/capabilities.py +12 -0
- topicgate/mcp/instructions.py +3 -0
- topicgate/mcp/middleware/__init__.py +6 -0
- topicgate/mcp/middleware/error_handling_middleware.py +49 -0
- topicgate/mcp/middleware/logging_middleware.py +43 -0
- topicgate/mcp/models.py +28 -0
- topicgate/mcp/server.py +147 -0
- topicgate/paths.py +36 -0
- topicgate/presentation/__init__.py +39 -0
- topicgate/presentation/retention_presentation.py +187 -0
- topicgate/presentation/snapshot_presentation.py +130 -0
- topicgate/presentation/topic_presentation.py +482 -0
- topicgate/processors/__init__.py +0 -0
- topicgate/processors/observation_retention_processor.py +35 -0
- topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
- topicgate/processors/observer_model_processor.py +223 -0
- topicgate/processors/subscription_manager.py +90 -0
- topicgate-1.1.0.dist-info/METADATA +222 -0
- topicgate-1.1.0.dist-info/RECORD +148 -0
- topicgate-1.1.0.dist-info/WHEEL +5 -0
- topicgate-1.1.0.dist-info/entry_points.txt +3 -0
- topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
- topicgate-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from collections.abc import AsyncIterator, Callable
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from topicgate.app.services.service_item import ServiceItem
|
|
9
|
+
from topicgate.app.services.observation_cache_service import ObservationCacheService
|
|
10
|
+
from topicgate.app.services.control_operation_service import ControlOperationService
|
|
11
|
+
from topicgate.core.config.mqtt_config import MqttConfig
|
|
12
|
+
from topicgate.core.interfaces.broker_profile_store import BrokerProfileStore
|
|
13
|
+
from topicgate.core.interfaces.observer_repository import ObserverRepository
|
|
14
|
+
from topicgate.core.models.broker_profile import BrokerProfile
|
|
15
|
+
from topicgate.core.models.broker_summary import BrokerSummary
|
|
16
|
+
from topicgate.core.models.mqtt_message import MqttMessage
|
|
17
|
+
from topicgate.core.models.mqtt_observation import MqttObservation
|
|
18
|
+
from topicgate.core.models.observation_deletion_preview import (
|
|
19
|
+
ObservationDeletionPreview,
|
|
20
|
+
)
|
|
21
|
+
from topicgate.core.models.observation_cache_administration import (
|
|
22
|
+
BrokerCacheUsage,
|
|
23
|
+
CacheUsageSummary,
|
|
24
|
+
ObservationDeletionResult,
|
|
25
|
+
PersistedTopicSummary,
|
|
26
|
+
RetentionPolicyApplicationResult,
|
|
27
|
+
RetentionPolicyPreview,
|
|
28
|
+
)
|
|
29
|
+
from topicgate.core.models.observation_retention_policy import (
|
|
30
|
+
ObservationRetentionPolicy,
|
|
31
|
+
)
|
|
32
|
+
from topicgate.core.models.observer_model import ObserverModel
|
|
33
|
+
from topicgate.core.models.subscription import Subscription
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TopicGateRuntime(ServiceItem):
|
|
37
|
+
"""Qt-independent application service for broker-backed topic access."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
broker_repository: BrokerProfileStore,
|
|
42
|
+
mqtt_repositories: dict[UUID, ObserverRepository],
|
|
43
|
+
active_broker_id: UUID | None = None,
|
|
44
|
+
mqtt_repository_factory: Callable[
|
|
45
|
+
[BrokerProfile], ObserverRepository
|
|
46
|
+
] | None = None,
|
|
47
|
+
observation_cache: ObservationCacheService | None = None,
|
|
48
|
+
control_operations: ControlOperationService | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self._brokers = broker_repository
|
|
51
|
+
self._active_broker_id = (
|
|
52
|
+
broker_repository.get_profile().id
|
|
53
|
+
if active_broker_id is None
|
|
54
|
+
else active_broker_id
|
|
55
|
+
)
|
|
56
|
+
self._mqtt_repositories = mqtt_repositories
|
|
57
|
+
self._mqtt_repository_factory = mqtt_repository_factory
|
|
58
|
+
self._observation_cache = observation_cache
|
|
59
|
+
self._control_operations = control_operations
|
|
60
|
+
if self._active_broker_id not in self._mqtt_repositories:
|
|
61
|
+
raise ValueError("The active broker requires an MQTT repository.")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def active_repo(self) -> ObserverRepository:
|
|
65
|
+
return self._mqtt_repositories[self._active_broker_id]
|
|
66
|
+
|
|
67
|
+
async def start(self) -> None:
|
|
68
|
+
await self.active_repo.start()
|
|
69
|
+
|
|
70
|
+
async def stop(self) -> None:
|
|
71
|
+
model = self.active_repo.get()
|
|
72
|
+
try:
|
|
73
|
+
await self.active_repo.stop()
|
|
74
|
+
finally:
|
|
75
|
+
self._brokers.update_observer_model(model)
|
|
76
|
+
|
|
77
|
+
def list_brokers(self) -> tuple[BrokerSummary, ...]:
|
|
78
|
+
return tuple(
|
|
79
|
+
self._broker_summary(profile)
|
|
80
|
+
for profile in self._brokers.get_all_profiles()
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def list_topics(self, broker_id: UUID | None = None) -> tuple[str, ...]:
|
|
84
|
+
return self._repository_for(broker_id).get_all_topics()
|
|
85
|
+
|
|
86
|
+
def get_broker(self, broker_id: UUID | None = None) -> BrokerSummary:
|
|
87
|
+
return self._broker_summary(self._get_broker_profile(broker_id))
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def active_broker(self) -> BrokerSummary:
|
|
91
|
+
return self.get_broker()
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def mqtt_config(self) -> MqttConfig:
|
|
95
|
+
return self.active_broker.config
|
|
96
|
+
|
|
97
|
+
def get_topic_state(
|
|
98
|
+
self,
|
|
99
|
+
broker_id: UUID,
|
|
100
|
+
topic: str,
|
|
101
|
+
) -> MqttObservation | None:
|
|
102
|
+
return self._mqtt_repositories[broker_id].get_state(topic)
|
|
103
|
+
|
|
104
|
+
def get_observer_model(self, broker_id: UUID) -> ObserverModel:
|
|
105
|
+
return self._mqtt_repositories[broker_id].get()
|
|
106
|
+
|
|
107
|
+
def list_subscriptions(self, broker_id: UUID) -> tuple[Subscription, ...]:
|
|
108
|
+
return tuple(self._mqtt_repositories[broker_id].subscriptions)
|
|
109
|
+
|
|
110
|
+
def get_retention_policy(self) -> ObservationRetentionPolicy:
|
|
111
|
+
return self._require_observation_cache().get_retention_policy()
|
|
112
|
+
|
|
113
|
+
def update_retention_policy(
|
|
114
|
+
self,
|
|
115
|
+
policy: ObservationRetentionPolicy,
|
|
116
|
+
) -> ObservationRetentionPolicy:
|
|
117
|
+
with self.control_operation("update retention policy"):
|
|
118
|
+
return self._require_observation_cache().update_retention_policy(policy)
|
|
119
|
+
|
|
120
|
+
def get_cache_usage(self) -> CacheUsageSummary:
|
|
121
|
+
usage = self._require_observation_cache().get_cache_usage()
|
|
122
|
+
by_broker = {item.broker_id: item for item in usage.brokers}
|
|
123
|
+
return CacheUsageSummary(
|
|
124
|
+
tuple(
|
|
125
|
+
by_broker.get(
|
|
126
|
+
broker.id,
|
|
127
|
+
BrokerCacheUsage(broker.id, 0, 0, None, None),
|
|
128
|
+
)
|
|
129
|
+
for broker in self.list_brokers()
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def list_persisted_topics(
|
|
134
|
+
self,
|
|
135
|
+
broker_id: UUID,
|
|
136
|
+
) -> tuple[PersistedTopicSummary, ...]:
|
|
137
|
+
self._get_broker_profile(broker_id)
|
|
138
|
+
return self._require_observation_cache().get_persisted_topics(
|
|
139
|
+
broker_id,
|
|
140
|
+
self.list_subscriptions(broker_id),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def preview_retention_policy(
|
|
144
|
+
self,
|
|
145
|
+
policy: ObservationRetentionPolicy,
|
|
146
|
+
) -> RetentionPolicyPreview:
|
|
147
|
+
return self._require_observation_cache().preview_retention_policy(
|
|
148
|
+
policy,
|
|
149
|
+
self._subscriptions_by_broker(),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def confirm_retention_policy(
|
|
153
|
+
self,
|
|
154
|
+
preview: RetentionPolicyPreview,
|
|
155
|
+
) -> RetentionPolicyApplicationResult:
|
|
156
|
+
with self.control_operation("enforce retention policy"):
|
|
157
|
+
result = self._require_observation_cache().confirm_retention_policy(
|
|
158
|
+
preview,
|
|
159
|
+
self._subscriptions_by_broker(),
|
|
160
|
+
)
|
|
161
|
+
self._reconcile_deleted_entries(result.enforcement.deleted_entries)
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
def preview_clear_cache(
|
|
165
|
+
self,
|
|
166
|
+
broker_id: UUID,
|
|
167
|
+
topics: tuple[str, ...] | None = None,
|
|
168
|
+
) -> ObservationDeletionPreview:
|
|
169
|
+
self._get_broker_profile(broker_id)
|
|
170
|
+
return self._require_observation_cache().preview_clear_cache(
|
|
171
|
+
broker_id,
|
|
172
|
+
topics,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def preview_unsubscribed_cache(
|
|
176
|
+
self,
|
|
177
|
+
broker_id: UUID,
|
|
178
|
+
) -> ObservationDeletionPreview:
|
|
179
|
+
self._get_broker_profile(broker_id)
|
|
180
|
+
return self._require_observation_cache().preview_unsubscribed(
|
|
181
|
+
broker_id,
|
|
182
|
+
self.list_subscriptions(broker_id),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def preview_all_cache(self) -> ObservationDeletionPreview:
|
|
186
|
+
return self._require_observation_cache().preview_all()
|
|
187
|
+
|
|
188
|
+
def confirm_cache_deletion_detailed(
|
|
189
|
+
self,
|
|
190
|
+
preview: ObservationDeletionPreview,
|
|
191
|
+
) -> ObservationDeletionResult:
|
|
192
|
+
with self.control_operation("delete stored observations"):
|
|
193
|
+
for broker_id in preview.broker_ids:
|
|
194
|
+
self._get_broker_profile(broker_id)
|
|
195
|
+
result = self._require_observation_cache().confirm_deletion_detailed(
|
|
196
|
+
preview
|
|
197
|
+
)
|
|
198
|
+
self._reconcile_deleted_entries(result.deleted_entries)
|
|
199
|
+
return result
|
|
200
|
+
|
|
201
|
+
def confirm_cache_deletion(
|
|
202
|
+
self,
|
|
203
|
+
preview: ObservationDeletionPreview,
|
|
204
|
+
) -> int:
|
|
205
|
+
with self.control_operation("delete stored observations"):
|
|
206
|
+
if preview.broker_id is not None:
|
|
207
|
+
self._get_broker_profile(preview.broker_id)
|
|
208
|
+
return self._require_observation_cache().confirm_deletion(preview)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def connection_status(self) -> object:
|
|
212
|
+
return self.get_connection_status(self._active_broker_id)
|
|
213
|
+
|
|
214
|
+
def get_connection_status(self, broker_id: UUID) -> object:
|
|
215
|
+
return self._repository_for(broker_id).connection_status
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def dropped_message_count(self) -> int:
|
|
219
|
+
return self.get_dropped_message_count(self._active_broker_id)
|
|
220
|
+
|
|
221
|
+
def get_dropped_message_count(self, broker_id: UUID) -> int:
|
|
222
|
+
return self._repository_for(broker_id).dropped_message_count
|
|
223
|
+
|
|
224
|
+
def get_connected_at(self, broker_id: UUID) -> datetime | None:
|
|
225
|
+
return getattr(self._repository_for(broker_id), "connected_at", None)
|
|
226
|
+
|
|
227
|
+
def get_observation_started_at(self, broker_id: UUID) -> datetime | None:
|
|
228
|
+
return getattr(
|
|
229
|
+
self._repository_for(broker_id),
|
|
230
|
+
"observation_started_at",
|
|
231
|
+
None,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def topic_update_interval(self) -> float:
|
|
236
|
+
return self.get_topic_update_interval(self._active_broker_id)
|
|
237
|
+
|
|
238
|
+
def get_topic_update_interval(self, broker_id: UUID) -> float:
|
|
239
|
+
return self._repository_for(broker_id).topic_update_interval
|
|
240
|
+
|
|
241
|
+
def messages(self) -> AsyncIterator[MqttMessage]:
|
|
242
|
+
return self.active_repo.messages()
|
|
243
|
+
|
|
244
|
+
def drain_pending_messages(self) -> tuple[MqttMessage, ...]:
|
|
245
|
+
return self.active_repo.drain_pending_messages()
|
|
246
|
+
|
|
247
|
+
def connection_statuses(self) -> AsyncIterator[object]:
|
|
248
|
+
return self.active_repo.connection_statuses()
|
|
249
|
+
|
|
250
|
+
async def connect(self) -> None:
|
|
251
|
+
with self.control_operation("connect broker"):
|
|
252
|
+
await self.active_repo.connect()
|
|
253
|
+
|
|
254
|
+
async def disconnect(self) -> None:
|
|
255
|
+
with self.control_operation("disconnect broker"):
|
|
256
|
+
await self.active_repo.disconnect()
|
|
257
|
+
|
|
258
|
+
async def reconnect(self) -> None:
|
|
259
|
+
with self.control_operation("reconnect broker"):
|
|
260
|
+
await self.active_repo.reconnect()
|
|
261
|
+
|
|
262
|
+
def create_broker(self, name: str, mqtt_config: MqttConfig) -> BrokerSummary:
|
|
263
|
+
with self.control_operation("create broker profile"):
|
|
264
|
+
if self._mqtt_repository_factory is None:
|
|
265
|
+
raise RuntimeError("An observer repository factory is required.")
|
|
266
|
+
profile = self._brokers.create_profile(name, mqtt_config)
|
|
267
|
+
self._mqtt_repositories[profile.id] = self._mqtt_repository_factory(profile)
|
|
268
|
+
return self._broker_summary(profile)
|
|
269
|
+
|
|
270
|
+
def update_broker(
|
|
271
|
+
self,
|
|
272
|
+
broker_id: UUID,
|
|
273
|
+
mqtt_config: MqttConfig,
|
|
274
|
+
name: str | None = None,
|
|
275
|
+
) -> BrokerSummary:
|
|
276
|
+
with self.control_operation("update broker profile"):
|
|
277
|
+
profile = self._get_broker_profile(broker_id)
|
|
278
|
+
config = self._config_with_stored_password(profile, mqtt_config)
|
|
279
|
+
profile.name = (
|
|
280
|
+
self._validated_profile_name(name, broker_id)
|
|
281
|
+
if name is not None
|
|
282
|
+
else profile.name
|
|
283
|
+
)
|
|
284
|
+
profile.config = config
|
|
285
|
+
self._brokers.update_profile(profile)
|
|
286
|
+
return self.get_broker(broker_id)
|
|
287
|
+
|
|
288
|
+
async def activate_broker(
|
|
289
|
+
self,
|
|
290
|
+
broker_id: UUID,
|
|
291
|
+
mqtt_config: MqttConfig | None = None,
|
|
292
|
+
name: str | None = None,
|
|
293
|
+
) -> BrokerSummary:
|
|
294
|
+
with self.control_operation("activate broker profile"):
|
|
295
|
+
return await self._activate_broker(broker_id, mqtt_config, name)
|
|
296
|
+
|
|
297
|
+
async def _activate_broker(
|
|
298
|
+
self,
|
|
299
|
+
broker_id: UUID,
|
|
300
|
+
mqtt_config: MqttConfig | None,
|
|
301
|
+
name: str | None,
|
|
302
|
+
) -> BrokerSummary:
|
|
303
|
+
profile = self._get_broker_profile(broker_id)
|
|
304
|
+
config = (
|
|
305
|
+
profile.config
|
|
306
|
+
if mqtt_config is None
|
|
307
|
+
else self._config_with_stored_password(profile, mqtt_config)
|
|
308
|
+
)
|
|
309
|
+
normalized_name = (
|
|
310
|
+
self._validated_profile_name(name, broker_id)
|
|
311
|
+
if name is not None
|
|
312
|
+
else profile.name
|
|
313
|
+
)
|
|
314
|
+
previous_broker_id = self._active_broker_id
|
|
315
|
+
previous_repo = self.active_repo
|
|
316
|
+
selected_repo = self._mqtt_repositories[broker_id]
|
|
317
|
+
switching_repositories = selected_repo is not previous_repo
|
|
318
|
+
if switching_repositories:
|
|
319
|
+
await previous_repo.stop()
|
|
320
|
+
try:
|
|
321
|
+
await selected_repo.update_broker(
|
|
322
|
+
config,
|
|
323
|
+
subscriptions=profile.workspace.subscriptions,
|
|
324
|
+
)
|
|
325
|
+
except Exception:
|
|
326
|
+
if switching_repositories:
|
|
327
|
+
await previous_repo.start()
|
|
328
|
+
raise
|
|
329
|
+
profile.name = normalized_name
|
|
330
|
+
profile.config = config
|
|
331
|
+
self._brokers.update_profile(profile)
|
|
332
|
+
if broker_id != previous_broker_id:
|
|
333
|
+
self._brokers.update_observer_model(previous_repo.get())
|
|
334
|
+
self._brokers.select_active_profile(broker_id)
|
|
335
|
+
if broker_id != previous_broker_id:
|
|
336
|
+
self._active_broker_id = broker_id
|
|
337
|
+
return self.active_broker
|
|
338
|
+
|
|
339
|
+
async def delete_broker(self, broker_id: UUID) -> BrokerSummary:
|
|
340
|
+
with self.control_operation("delete broker profile"):
|
|
341
|
+
profile = self._get_broker_profile(broker_id)
|
|
342
|
+
profiles = self.list_brokers()
|
|
343
|
+
if len(profiles) == 1:
|
|
344
|
+
raise ValueError("At least one broker profile is required.")
|
|
345
|
+
if profile.id == self.active_broker.id:
|
|
346
|
+
replacement = next(item for item in profiles if item.id != profile.id)
|
|
347
|
+
await self.activate_broker(replacement.id)
|
|
348
|
+
if self._observation_cache is not None:
|
|
349
|
+
self._observation_cache.flush_pending_writes()
|
|
350
|
+
deleted = self._brokers.delete_profile(profile.id)
|
|
351
|
+
self._mqtt_repositories.pop(profile.id)
|
|
352
|
+
return self._broker_summary(deleted)
|
|
353
|
+
|
|
354
|
+
async def add_subscription(
|
|
355
|
+
self,
|
|
356
|
+
broker_id: UUID,
|
|
357
|
+
subscription: Subscription,
|
|
358
|
+
) -> None:
|
|
359
|
+
with self.control_operation("add subscription"):
|
|
360
|
+
self._require_active_broker(broker_id)
|
|
361
|
+
await self.active_repo.add_subscription(subscription)
|
|
362
|
+
self._persist_active_subscriptions()
|
|
363
|
+
|
|
364
|
+
async def update_subscription(
|
|
365
|
+
self,
|
|
366
|
+
broker_id: UUID,
|
|
367
|
+
original_filter: str,
|
|
368
|
+
subscription: Subscription,
|
|
369
|
+
) -> ObservationDeletionResult | None:
|
|
370
|
+
with self.control_operation("update subscription"):
|
|
371
|
+
self._require_active_broker(broker_id)
|
|
372
|
+
await self.active_repo.update_subscription(original_filter, subscription)
|
|
373
|
+
self._persist_active_subscriptions()
|
|
374
|
+
return self._cleanup_unsubscribed_if_enabled(broker_id)
|
|
375
|
+
|
|
376
|
+
async def remove_subscription(
|
|
377
|
+
self,
|
|
378
|
+
broker_id: UUID,
|
|
379
|
+
subscription: Subscription,
|
|
380
|
+
) -> ObservationDeletionResult | None:
|
|
381
|
+
with self.control_operation("remove subscription"):
|
|
382
|
+
self._require_active_broker(broker_id)
|
|
383
|
+
await self.active_repo.remove_subscription(subscription)
|
|
384
|
+
self._persist_active_subscriptions()
|
|
385
|
+
return self._cleanup_unsubscribed_if_enabled(broker_id)
|
|
386
|
+
|
|
387
|
+
async def publish(self, broker_id: UUID, topic: str, payload: bytes) -> None:
|
|
388
|
+
with self.control_operation("publish MQTT message"):
|
|
389
|
+
self._require_active_broker(broker_id)
|
|
390
|
+
await self.active_repo.publish(topic, payload)
|
|
391
|
+
|
|
392
|
+
def control_operation(self, name: str):
|
|
393
|
+
if self._control_operations is None:
|
|
394
|
+
return nullcontext()
|
|
395
|
+
return self._control_operations.operation(name)
|
|
396
|
+
|
|
397
|
+
def _persist_active_subscriptions(self) -> None:
|
|
398
|
+
workspace_id = self._get_broker_profile().workspace_id
|
|
399
|
+
self._brokers.replace_subscriptions(
|
|
400
|
+
workspace_id,
|
|
401
|
+
tuple(self.active_repo.subscriptions),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
def _cleanup_unsubscribed_if_enabled(
|
|
405
|
+
self,
|
|
406
|
+
broker_id: UUID,
|
|
407
|
+
) -> ObservationDeletionResult | None:
|
|
408
|
+
cache = self._observation_cache
|
|
409
|
+
if cache is None or not cache.get_retention_policy().auto_remove_unsubscribed:
|
|
410
|
+
return None
|
|
411
|
+
preview = cache.preview_unsubscribed(
|
|
412
|
+
broker_id,
|
|
413
|
+
self.list_subscriptions(broker_id),
|
|
414
|
+
)
|
|
415
|
+
if preview.entries:
|
|
416
|
+
result = cache.confirm_deletion_detailed(preview)
|
|
417
|
+
self._reconcile_deleted_entries(result.deleted_entries)
|
|
418
|
+
return result
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
def _subscriptions_by_broker(
|
|
422
|
+
self,
|
|
423
|
+
) -> dict[UUID, tuple[Subscription, ...]]:
|
|
424
|
+
return {
|
|
425
|
+
broker.id: self.list_subscriptions(broker.id)
|
|
426
|
+
for broker in self.list_brokers()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
def _reconcile_deleted_entries(self, entries) -> int:
|
|
430
|
+
grouped = defaultdict(list)
|
|
431
|
+
for entry in entries:
|
|
432
|
+
grouped[entry.broker_id].append(entry)
|
|
433
|
+
removed = 0
|
|
434
|
+
for broker_id, broker_entries in grouped.items():
|
|
435
|
+
repository = self._repository_for(broker_id)
|
|
436
|
+
evict = getattr(repository, "evict_stored_observations", None)
|
|
437
|
+
if evict is not None:
|
|
438
|
+
removed += evict(tuple(broker_entries))
|
|
439
|
+
return removed
|
|
440
|
+
|
|
441
|
+
def _require_active_broker(self, broker_id: UUID) -> None:
|
|
442
|
+
if broker_id != self.active_broker.id:
|
|
443
|
+
raise ValueError("The operation requires the active broker profile.")
|
|
444
|
+
|
|
445
|
+
def _repository_for(self, broker_id: UUID | None) -> ObserverRepository:
|
|
446
|
+
selected_id = self._active_broker_id if broker_id is None else broker_id
|
|
447
|
+
try:
|
|
448
|
+
return self._mqtt_repositories[selected_id]
|
|
449
|
+
except KeyError as error:
|
|
450
|
+
raise KeyError(f"Unknown broker runtime: {selected_id}") from error
|
|
451
|
+
|
|
452
|
+
def _require_observation_cache(self) -> ObservationCacheService:
|
|
453
|
+
if self._observation_cache is None:
|
|
454
|
+
raise RuntimeError("Observation cache operations are unavailable.")
|
|
455
|
+
return self._observation_cache
|
|
456
|
+
|
|
457
|
+
def _validated_profile_name(self, name: str, broker_id: UUID) -> str:
|
|
458
|
+
normalized_name = name.strip()
|
|
459
|
+
if not normalized_name:
|
|
460
|
+
raise ValueError("A broker profile name is required.")
|
|
461
|
+
if any(
|
|
462
|
+
profile.id != broker_id
|
|
463
|
+
and profile.name.casefold() == normalized_name.casefold()
|
|
464
|
+
for profile in self.list_brokers()
|
|
465
|
+
):
|
|
466
|
+
raise ValueError("A broker profile with that name already exists.")
|
|
467
|
+
return normalized_name
|
|
468
|
+
|
|
469
|
+
def _get_broker_profile(self, broker_id: UUID | None = None) -> BrokerProfile:
|
|
470
|
+
return self._brokers.get_profile(broker_id)
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _config_with_stored_password(
|
|
474
|
+
profile: BrokerProfile,
|
|
475
|
+
mqtt_config: MqttConfig,
|
|
476
|
+
) -> MqttConfig:
|
|
477
|
+
if mqtt_config.password or not profile.config.password:
|
|
478
|
+
return mqtt_config
|
|
479
|
+
return replace(mqtt_config, password=profile.config.password)
|
|
480
|
+
|
|
481
|
+
@staticmethod
|
|
482
|
+
def _broker_summary(profile: BrokerProfile) -> BrokerSummary:
|
|
483
|
+
config = profile.config
|
|
484
|
+
return BrokerSummary(
|
|
485
|
+
id=profile.id,
|
|
486
|
+
name=profile.name,
|
|
487
|
+
config=MqttConfig(
|
|
488
|
+
host=config.host,
|
|
489
|
+
port=config.port,
|
|
490
|
+
username=config.username,
|
|
491
|
+
password="",
|
|
492
|
+
use_tls=config.use_tls,
|
|
493
|
+
id=config.id,
|
|
494
|
+
),
|
|
495
|
+
password_configured=bool(config.password),
|
|
496
|
+
)
|
|
Binary file
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
|
2
|
+
<title>TopicGate centered TG icon</title>
|
|
3
|
+
|
|
4
|
+
<!-- Background -->
|
|
5
|
+
<rect width="512" height="512" fill="#ffffff"/>
|
|
6
|
+
|
|
7
|
+
<!-- Rounded app frame -->
|
|
8
|
+
<rect
|
|
9
|
+
x="32" y="32" width="448" height="448" rx="96"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="#111111"
|
|
12
|
+
stroke-width="18"
|
|
13
|
+
/>
|
|
14
|
+
|
|
15
|
+
<!-- Centered TG monogram -->
|
|
16
|
+
<g
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="#111111"
|
|
19
|
+
stroke-width="34"
|
|
20
|
+
stroke-linecap="round"
|
|
21
|
+
stroke-linejoin="round"
|
|
22
|
+
>
|
|
23
|
+
<!-- T: x=110..220 -->
|
|
24
|
+
<path d="M110 166 H220 M165 166 V346"/>
|
|
25
|
+
|
|
26
|
+
<!-- G: x=240..400 -->
|
|
27
|
+
<path d="
|
|
28
|
+
M385 202
|
|
29
|
+
C370 179 347 166 320 166
|
|
30
|
+
C272 166 240 204 240 256
|
|
31
|
+
C240 308 272 346 320 346
|
|
32
|
+
C353 346 380 334 400 313
|
|
33
|
+
L400 264
|
|
34
|
+
H343
|
|
35
|
+
"/>
|
|
36
|
+
</g>
|
|
37
|
+
</svg>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from topicgate.core.config.mqtt_config import MqttConfig
|
|
5
|
+
from topicgate.core.models.broker_profile import BrokerProfile
|
|
6
|
+
from topicgate.core.models.observer_model import ObserverModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BrokerProfileStore(Protocol):
|
|
10
|
+
"""Store broker profiles and their observer workspaces."""
|
|
11
|
+
|
|
12
|
+
def get_profile(self, profile_id: UUID | None = None, /) -> BrokerProfile: ...
|
|
13
|
+
|
|
14
|
+
def get_all_profiles(self) -> tuple[BrokerProfile, ...]: ...
|
|
15
|
+
|
|
16
|
+
def create_profile(self, name: str, mqtt: MqttConfig, /) -> BrokerProfile: ...
|
|
17
|
+
|
|
18
|
+
def update_profile(self, profile: BrokerProfile, /) -> None: ...
|
|
19
|
+
|
|
20
|
+
def delete_profile(self, profile_id: UUID, /) -> BrokerProfile: ...
|
|
21
|
+
|
|
22
|
+
def select_active_profile(self, profile_id: UUID, /) -> None: ...
|
|
23
|
+
|
|
24
|
+
def replace_subscriptions(self, workspace_id: UUID, subscriptions, /) -> None: ...
|
|
25
|
+
|
|
26
|
+
def update_observer_model(self, model: ObserverModel, /) -> None: ...
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import TypeVar, Generic
|
|
3
|
+
|
|
4
|
+
from topicgate.core.models.mqtt_message import MqttMessage
|
|
5
|
+
from topicgate.core.models.mqtt_state import MqttState
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T", bound=MqttState)
|
|
8
|
+
|
|
9
|
+
class MqttMessageProcessor(ABC, Generic[T]):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def process(self, state: T, message: MqttMessage) -> bool:
|
|
12
|
+
"""Apply an admitted MQTT message and report whether it was retained."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator, Collection
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from topicgate.core.config.mqtt_config import MqttConfig
|
|
6
|
+
from topicgate.core.models.mqtt_message import MqttMessage
|
|
7
|
+
from topicgate.core.models.mqtt_observation import MqttObservation
|
|
8
|
+
from topicgate.core.models.observation_deletion_preview import (
|
|
9
|
+
ObservationDeletionEntry,
|
|
10
|
+
)
|
|
11
|
+
from topicgate.core.models.observer_model import ObserverModel
|
|
12
|
+
from topicgate.core.models.subscription import Subscription
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ObserverRepository(Protocol):
|
|
16
|
+
"""Application-facing access to an MQTT observer."""
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def connection_status(self) -> object: ...
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def topic_update_interval(self) -> float: ...
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def dropped_message_count(self) -> int: ...
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def connected_at(self) -> datetime | None: ...
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def observation_started_at(self) -> datetime | None: ...
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def subscriptions(self) -> tuple[Subscription, ...]: ...
|
|
35
|
+
|
|
36
|
+
async def start(self) -> None: ...
|
|
37
|
+
|
|
38
|
+
async def stop(self) -> None: ...
|
|
39
|
+
|
|
40
|
+
def get(self) -> ObserverModel: ...
|
|
41
|
+
|
|
42
|
+
def get_state(self, topic: str, /) -> MqttObservation | None: ...
|
|
43
|
+
|
|
44
|
+
def get_all_topics(self) -> tuple[str, ...]: ...
|
|
45
|
+
|
|
46
|
+
def messages(self) -> AsyncIterator[MqttMessage]: ...
|
|
47
|
+
|
|
48
|
+
def drain_pending_messages(self) -> tuple[MqttMessage, ...]: ...
|
|
49
|
+
|
|
50
|
+
def evict_stored_observations(
|
|
51
|
+
self,
|
|
52
|
+
entries: Collection[ObservationDeletionEntry],
|
|
53
|
+
) -> int: ...
|
|
54
|
+
|
|
55
|
+
def connection_statuses(self) -> AsyncIterator[object]: ...
|
|
56
|
+
|
|
57
|
+
async def connect(self) -> None: ...
|
|
58
|
+
|
|
59
|
+
async def disconnect(self) -> None: ...
|
|
60
|
+
|
|
61
|
+
async def reconnect(self) -> None: ...
|
|
62
|
+
|
|
63
|
+
async def update_broker(
|
|
64
|
+
self,
|
|
65
|
+
new_config: MqttConfig,
|
|
66
|
+
/,
|
|
67
|
+
model: ObserverModel | None = None,
|
|
68
|
+
subscriptions: tuple[Subscription, ...] | None = None,
|
|
69
|
+
) -> None: ...
|
|
70
|
+
|
|
71
|
+
async def add_subscription(self, subscription: Subscription, /) -> None: ...
|
|
72
|
+
|
|
73
|
+
async def update_subscription(
|
|
74
|
+
self,
|
|
75
|
+
original_filter: str,
|
|
76
|
+
subscription: Subscription,
|
|
77
|
+
/,
|
|
78
|
+
) -> None: ...
|
|
79
|
+
|
|
80
|
+
async def remove_subscription(self, subscription: Subscription, /) -> None: ...
|
|
81
|
+
|
|
82
|
+
async def publish(self, topic: str, payload: bytes, /) -> None: ...
|