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.
Files changed (148) hide show
  1. topicgate/__init__.py +0 -0
  2. topicgate/__main__.py +5 -0
  3. topicgate/app/__init__.py +0 -0
  4. topicgate/app/app_dependencies.py +148 -0
  5. topicgate/app/broker_runtime_state.py +43 -0
  6. topicgate/app/models/__init__.py +1 -0
  7. topicgate/app/models/broker_snapshot.py +107 -0
  8. topicgate/app/models/mcp_setup.py +20 -0
  9. topicgate/app/services/__init__.py +0 -0
  10. topicgate/app/services/broker_profile_service.py +245 -0
  11. topicgate/app/services/broker_resolver.py +58 -0
  12. topicgate/app/services/broker_snapshot_service.py +403 -0
  13. topicgate/app/services/control_operation_service.py +158 -0
  14. topicgate/app/services/mcp_setup_service.py +192 -0
  15. topicgate/app/services/observation_cache_service.py +256 -0
  16. topicgate/app/services/observation_retention_policy_service.py +33 -0
  17. topicgate/app/services/persistence_lifecycle.py +25 -0
  18. topicgate/app/services/service_container.py +20 -0
  19. topicgate/app/services/service_item.py +13 -0
  20. topicgate/app/topicgate_runtime.py +496 -0
  21. topicgate/assets/icon.png +0 -0
  22. topicgate/assets/icon.svg +37 -0
  23. topicgate/core/__init__.py +0 -0
  24. topicgate/core/config/__init__.py +0 -0
  25. topicgate/core/config/app_config.py +7 -0
  26. topicgate/core/config/mqtt_config.py +10 -0
  27. topicgate/core/interfaces/__init__.py +0 -0
  28. topicgate/core/interfaces/broker_profile_store.py +26 -0
  29. topicgate/core/interfaces/mqtt_message_processor.py +12 -0
  30. topicgate/core/interfaces/observer_repository.py +82 -0
  31. topicgate/core/interfaces/topic_message_store.py +13 -0
  32. topicgate/core/models/__init__.py +0 -0
  33. topicgate/core/models/broker_profile.py +15 -0
  34. topicgate/core/models/broker_profile_identity.py +14 -0
  35. topicgate/core/models/broker_summary.py +14 -0
  36. topicgate/core/models/connection_status.py +9 -0
  37. topicgate/core/models/message_filter.py +9 -0
  38. topicgate/core/models/mqtt_message.py +13 -0
  39. topicgate/core/models/mqtt_observation.py +36 -0
  40. topicgate/core/models/mqtt_state.py +5 -0
  41. topicgate/core/models/observation_cache_administration.py +135 -0
  42. topicgate/core/models/observation_deletion_preview.py +39 -0
  43. topicgate/core/models/observation_retention_policy.py +65 -0
  44. topicgate/core/models/observer_model.py +22 -0
  45. topicgate/core/models/observer_workspace.py +13 -0
  46. topicgate/core/models/subscription.py +29 -0
  47. topicgate/core/models/topic_message.py +16 -0
  48. topicgate/core/mqtt_topics.py +46 -0
  49. topicgate/core/observer_limits.py +8 -0
  50. topicgate/core/payload_limits.py +6 -0
  51. topicgate/gui/__init__.py +0 -0
  52. topicgate/gui/app.py +97 -0
  53. topicgate/gui/broker_state_reader.py +29 -0
  54. topicgate/gui/components/about_dialog.py +100 -0
  55. topicgate/gui/components/add_subscription_dialog.py +59 -0
  56. topicgate/gui/components/application_header.py +137 -0
  57. topicgate/gui/components/broker_settings_dialog.py +223 -0
  58. topicgate/gui/components/connection_controls.py +123 -0
  59. topicgate/gui/components/connection_status.py +7 -0
  60. topicgate/gui/components/log_console.py +26 -0
  61. topicgate/gui/components/mcp_setup_dialog.py +198 -0
  62. topicgate/gui/components/observer_tree.py +434 -0
  63. topicgate/gui/components/onboarding_panel.py +74 -0
  64. topicgate/gui/components/publish_pane.py +60 -0
  65. topicgate/gui/components/snapshot_panel.py +343 -0
  66. topicgate/gui/components/stored_observations_dialog.py +611 -0
  67. topicgate/gui/components/subscription_settings.py +151 -0
  68. topicgate/gui/components/topic_details.py +51 -0
  69. topicgate/gui/components/topic_metadata.py +79 -0
  70. topicgate/gui/components/workspace_pane.py +30 -0
  71. topicgate/gui/gui.py +5 -0
  72. topicgate/gui/main_view_model.py +814 -0
  73. topicgate/gui/main_window.py +986 -0
  74. topicgate/gui/observer_state_reader.py +47 -0
  75. topicgate/gui/settings_migration.py +32 -0
  76. topicgate/gui/theme.py +61 -0
  77. topicgate/infrastructure/__init__.py +0 -0
  78. topicgate/infrastructure/credentials/__init__.py +1 -0
  79. topicgate/infrastructure/credentials/credential_store.py +10 -0
  80. topicgate/infrastructure/credentials/os_credential_store.py +20 -0
  81. topicgate/infrastructure/database/__init__.py +0 -0
  82. topicgate/infrastructure/database/base.py +5 -0
  83. topicgate/infrastructure/database/database_context.py +56 -0
  84. topicgate/infrastructure/database/mappers/broker_profile_mapper.py +42 -0
  85. topicgate/infrastructure/database/mappers/config_mapper.py +48 -0
  86. topicgate/infrastructure/database/mappers/mapper_helper.py +24 -0
  87. topicgate/infrastructure/database/mappers/observation_retention_policy_mapper.py +53 -0
  88. topicgate/infrastructure/database/mappers/observer_workspace_mapper.py +42 -0
  89. topicgate/infrastructure/database/mappers/subscription_mapper.py +38 -0
  90. topicgate/infrastructure/database/mappers/topic_message_mapper.py +37 -0
  91. topicgate/infrastructure/database/migrations.py +57 -0
  92. topicgate/infrastructure/database/models/__init__.py +25 -0
  93. topicgate/infrastructure/database/models/app_config_row.py +18 -0
  94. topicgate/infrastructure/database/models/broker_profile_row.py +35 -0
  95. topicgate/infrastructure/database/models/mqtt_config_row.py +18 -0
  96. topicgate/infrastructure/database/models/mqtt_message_row.py +33 -0
  97. topicgate/infrastructure/database/models/observation_retention_policy_row.py +59 -0
  98. topicgate/infrastructure/database/models/observer_workspace_row.py +39 -0
  99. topicgate/infrastructure/database/models/subscription_row.py +18 -0
  100. topicgate/infrastructure/mqtt/__init__.py +0 -0
  101. topicgate/infrastructure/mqtt/async_callback_bridge.py +105 -0
  102. topicgate/infrastructure/mqtt/callbacks/__init__.py +0 -0
  103. topicgate/infrastructure/mqtt/callbacks/basic_callbacks.py +61 -0
  104. topicgate/infrastructure/mqtt/callbacks/observer_repository_callbacks.py +79 -0
  105. topicgate/infrastructure/mqtt/mqtt_callbacks.py +53 -0
  106. topicgate/infrastructure/mqtt/mqtt_client.py +356 -0
  107. topicgate/infrastructure/mqtt/mqtt_gate.py +122 -0
  108. topicgate/infrastructure/repository/__init__.py +0 -0
  109. topicgate/infrastructure/repository/broker_config_repository.py +68 -0
  110. topicgate/infrastructure/repository/broker_repository.py +170 -0
  111. topicgate/infrastructure/repository/observation_retention_policy_repository.py +42 -0
  112. topicgate/infrastructure/repository/observer_mqtt_repository.py +335 -0
  113. topicgate/infrastructure/repository/subscription_repository.py +117 -0
  114. topicgate/infrastructure/repository/topic_message_repository.py +438 -0
  115. topicgate/mcp/__init__.py +0 -0
  116. topicgate/mcp/api/broker_api.py +48 -0
  117. topicgate/mcp/api/connection_api.py +83 -0
  118. topicgate/mcp/api/dashboard_api.py +790 -0
  119. topicgate/mcp/api/dashboard_snapshot.py +278 -0
  120. topicgate/mcp/api/mcp_api.py +18 -0
  121. topicgate/mcp/api/preview/dashboard_preview.py +6 -0
  122. topicgate/mcp/api/publish_api.py +62 -0
  123. topicgate/mcp/api/snapshot_api.py +78 -0
  124. topicgate/mcp/api/subscription_api.py +134 -0
  125. topicgate/mcp/api/topic_api.py +77 -0
  126. topicgate/mcp/capabilities.py +12 -0
  127. topicgate/mcp/instructions.py +3 -0
  128. topicgate/mcp/middleware/__init__.py +6 -0
  129. topicgate/mcp/middleware/error_handling_middleware.py +49 -0
  130. topicgate/mcp/middleware/logging_middleware.py +43 -0
  131. topicgate/mcp/models.py +28 -0
  132. topicgate/mcp/server.py +147 -0
  133. topicgate/paths.py +36 -0
  134. topicgate/presentation/__init__.py +39 -0
  135. topicgate/presentation/retention_presentation.py +187 -0
  136. topicgate/presentation/snapshot_presentation.py +130 -0
  137. topicgate/presentation/topic_presentation.py +482 -0
  138. topicgate/processors/__init__.py +0 -0
  139. topicgate/processors/observation_retention_processor.py +35 -0
  140. topicgate/processors/observer_model_mqtt_message_processor.py +79 -0
  141. topicgate/processors/observer_model_processor.py +223 -0
  142. topicgate/processors/subscription_manager.py +90 -0
  143. topicgate-1.1.0.dist-info/METADATA +222 -0
  144. topicgate-1.1.0.dist-info/RECORD +148 -0
  145. topicgate-1.1.0.dist-info/WHEEL +5 -0
  146. topicgate-1.1.0.dist-info/entry_points.txt +3 -0
  147. topicgate-1.1.0.dist-info/licenses/LICENCE +21 -0
  148. 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
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+ from .mqtt_config import MqttConfig
3
+
4
+ @dataclass
5
+ class AppConfig:
6
+ mqtt: MqttConfig
7
+ id: int | None = None
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass(frozen=True)
4
+ class MqttConfig:
5
+ host: str
6
+ port: int
7
+ username: str
8
+ password: str
9
+ use_tls: bool = False
10
+ id: int | None = None
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: ...