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
topicgate/__init__.py ADDED
File without changes
topicgate/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from topicgate.mcp.server import run
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(run())
File without changes
@@ -0,0 +1,148 @@
1
+ from pathlib import Path
2
+ from uuid import UUID
3
+
4
+ from topicgate.app.services.service_item import ServiceItem
5
+ from topicgate.app.services.persistence_lifecycle import PersistenceLifecycle
6
+ from topicgate.app.services.broker_profile_service import BrokerProfileService
7
+ from topicgate.app.services.observation_cache_service import ObservationCacheService
8
+ from topicgate.app.services.observation_retention_policy_service import (
9
+ ObservationRetentionPolicyService,
10
+ )
11
+ from topicgate.app.services.broker_snapshot_service import BrokerSnapshotService
12
+ from topicgate.app.services.control_operation_service import ControlOperationService
13
+ from topicgate.app.services.mcp_setup_service import McpSetupService
14
+ from topicgate.app.broker_runtime_state import BrokerRuntimeState
15
+ from topicgate.app.topicgate_runtime import TopicGateRuntime
16
+ from topicgate.core.interfaces.observer_repository import ObserverRepository
17
+ from topicgate.core.models.broker_profile import BrokerProfile
18
+ from topicgate.core.models.mqtt_observation import MqttObservation
19
+ from topicgate.core.models.topic_message import TopicMessage
20
+ from topicgate.infrastructure.database.database_context import DatabaseContext
21
+ from topicgate.infrastructure.credentials.credential_store import CredentialStore
22
+ from topicgate.infrastructure.credentials.os_credential_store import OSCredentialStore
23
+ from topicgate.infrastructure.repository.observer_mqtt_repository import (
24
+ ObserverMqttRepository,
25
+ )
26
+ from topicgate.infrastructure.repository.topic_message_repository import (
27
+ TopicMessageRepository,
28
+ )
29
+ from topicgate.infrastructure.repository.observation_retention_policy_repository import (
30
+ ObservationRetentionPolicyRepository,
31
+ )
32
+ from topicgate.paths import prepare_database_path, sqlite_url
33
+
34
+
35
+ class AppDependencies:
36
+ """Build application components and expose their lifecycle order."""
37
+
38
+ def __init__(
39
+ self,
40
+ data_dir: Path | None = None,
41
+ credential_store: CredentialStore | None = None,
42
+ *,
43
+ control_owner: str = "application",
44
+ ) -> None:
45
+
46
+ database_path = prepare_database_path(data_dir)
47
+ self._db_context = DatabaseContext(sqlite_url(database_path))
48
+ self.database_path = database_path
49
+ self.credential_store = (
50
+ OSCredentialStore() if credential_store is None else credential_store
51
+ )
52
+
53
+ self.broker_runtime_state = BrokerRuntimeState()
54
+ self.observation_retention_policy = ObservationRetentionPolicyRepository(
55
+ self._db_context
56
+ )
57
+ self.retention_policy = ObservationRetentionPolicyService(
58
+ self.observation_retention_policy
59
+ )
60
+ self.topic_messages = TopicMessageRepository(
61
+ self._db_context,
62
+ policy_provider=self.retention_policy.get,
63
+ )
64
+ self.observation_cache = ObservationCacheService(
65
+ self.topic_messages,
66
+ self.retention_policy,
67
+ )
68
+ self.control_operations = ControlOperationService(
69
+ self._db_context,
70
+ control_owner,
71
+ )
72
+ self.persistence = PersistenceLifecycle(
73
+ self.topic_messages,
74
+ self._db_context,
75
+ )
76
+ self.broker_profiles = BrokerProfileService(
77
+ self._db_context,
78
+ credential_store=self.credential_store,
79
+ runtime_state=self.broker_runtime_state,
80
+ topic_messages=self.topic_messages,
81
+ )
82
+ profile = self.broker_profiles.get_profile()
83
+
84
+ self.broker_runtime_state.repositories.update(
85
+ {
86
+ item.id: self._create_observer_repository(item)
87
+ for item in self.broker_profiles.get_all_profiles()
88
+ }
89
+ )
90
+
91
+ self.runtime = TopicGateRuntime(
92
+ self.broker_profiles,
93
+ self.broker_runtime_state.repositories,
94
+ profile.id,
95
+ self._create_observer_repository,
96
+ self.observation_cache,
97
+ self.control_operations,
98
+ )
99
+ self.snapshot_service = BrokerSnapshotService(self.runtime)
100
+ self.mcp_setup = McpSetupService(
101
+ self.runtime,
102
+ self.snapshot_service,
103
+ self._db_context,
104
+ self.credential_store,
105
+ database_path.parent,
106
+ database_path,
107
+ )
108
+
109
+ self.service_items: tuple[ServiceItem, ...] = (
110
+ self.persistence,
111
+ self.runtime,
112
+ )
113
+
114
+ def _create_observer_repository(
115
+ self,
116
+ profile: BrokerProfile,
117
+ ) -> ObserverRepository:
118
+ return ObserverMqttRepository(
119
+ profile.config,
120
+ list(profile.workspace.subscriptions),
121
+ profile.workspace.model,
122
+ retention_policy=self.retention_policy.get,
123
+ observation_sink=lambda observation: self._persist_observation(
124
+ profile.id,
125
+ observation,
126
+ ),
127
+ )
128
+
129
+ def _persist_observation(
130
+ self,
131
+ broker_id: UUID,
132
+ observation: MqttObservation,
133
+ ) -> None:
134
+ if observation.observation_id is None:
135
+ raise ValueError("A live observation requires an observation ID.")
136
+ self.topic_messages.update_message(
137
+ TopicMessage(
138
+ broker_id=broker_id,
139
+ topic=observation.topic,
140
+ payload=observation.payload,
141
+ qos=observation.qos,
142
+ retain=observation.retain,
143
+ received_at=observation.received_at,
144
+ payload_size=observation.payload_size or len(observation.payload),
145
+ message_count=observation.message_count,
146
+ observation_id=observation.observation_id,
147
+ )
148
+ )
@@ -0,0 +1,43 @@
1
+ from uuid import UUID
2
+
3
+ from topicgate.core.interfaces.observer_repository import ObserverRepository
4
+ from topicgate.core.models.observer_model import ObserverModel
5
+ from topicgate.core.models.broker_profile import BrokerProfile
6
+
7
+
8
+ class BrokerRuntimeState:
9
+ """In-memory observer models and repositories, keyed by broker identity."""
10
+
11
+ def __init__(self) -> None:
12
+ self._models: dict[UUID, ObserverModel] = {}
13
+ self._repositories: dict[UUID, ObserverRepository] = {}
14
+ self._profile_handles: dict[UUID, BrokerProfile] = {}
15
+ self._config_ids: dict[UUID, int | None] = {}
16
+
17
+ def get_model(self, broker_id: UUID) -> ObserverModel | None:
18
+ return self._models.get(broker_id)
19
+
20
+ def set_model(self, broker_id: UUID, model: ObserverModel) -> None:
21
+ self._models[broker_id] = model
22
+
23
+ def remove(self, broker_id: UUID) -> None:
24
+ self._models.pop(broker_id, None)
25
+ self._repositories.pop(broker_id, None)
26
+ self._profile_handles.pop(broker_id, None)
27
+ self._config_ids.pop(broker_id, None)
28
+
29
+ def get_config_id(self, broker_id: UUID, persisted_id: int | None) -> int | None:
30
+ return self._config_ids.get(broker_id, persisted_id)
31
+
32
+ def set_config_id(self, broker_id: UUID, config_id: int | None) -> None:
33
+ self._config_ids[broker_id] = config_id
34
+
35
+ def get_profile_handle(self, broker_id: UUID) -> BrokerProfile | None:
36
+ return self._profile_handles.get(broker_id)
37
+
38
+ def set_profile_handle(self, profile: BrokerProfile) -> None:
39
+ self._profile_handles[profile.id] = profile
40
+
41
+ @property
42
+ def repositories(self) -> dict[UUID, ObserverRepository]:
43
+ return self._repositories
@@ -0,0 +1 @@
1
+ """Application-level read models."""
@@ -0,0 +1,107 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+ from enum import StrEnum
4
+ from uuid import UUID
5
+
6
+ from topicgate.core.models.mqtt_observation import ObservationSource
7
+
8
+
9
+ class SnapshotPayloadEncoding(StrEnum):
10
+ UTF8 = "utf-8"
11
+ BASE64 = "base64"
12
+
13
+
14
+ class SnapshotTopicStatus(StrEnum):
15
+ LIVE = "live"
16
+ CACHED = "cached"
17
+ STALE = "stale"
18
+
19
+
20
+ class SnapshotLimitation(StrEnum):
21
+ CURRENT_STATE_ONLY = "current_state_only"
22
+ RETAINED_DELIVERY_UNCONFIRMED = "retained_delivery_unconfirmed"
23
+ BROKER_DISCONNECTED = "broker_disconnected"
24
+ OBSERVATION_NOT_STARTED = "observation_not_started"
25
+ STORED_STATE_PREDATES_OBSERVATION = "stored_state_predates_observation"
26
+ DROPPED_MESSAGES = "dropped_messages"
27
+ STALE_STATES_OMITTED = "stale_states_omitted"
28
+ RESULT_LIMIT_REACHED = "result_limit_reached"
29
+ PAYLOAD_TRUNCATED = "payload_truncated"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SnapshotBrokerIdentity:
34
+ id: UUID
35
+ name: str
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SnapshotPayload:
40
+ encoding: SnapshotPayloadEncoding
41
+ value: str
42
+ original_size: int
43
+ available_size: int
44
+ rendered_size: int
45
+ ingestion_truncated: bool
46
+ rendering_truncated: bool
47
+ truncated: bool
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class SnapshotTopicState:
52
+ topic: str
53
+ payload: SnapshotPayload
54
+ qos: int
55
+ retain: bool
56
+ received_at: datetime
57
+ age_seconds: float
58
+ message_count: int
59
+ source: ObservationSource
60
+ status: SnapshotTopicStatus
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class SnapshotFreshness:
65
+ max_age_seconds: float | None
66
+ stale_count: int
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class SnapshotResultLimit:
71
+ limit: int
72
+ total: int
73
+ returned: int
74
+ omitted: int
75
+ omitted_as_stale: int
76
+ omitted_by_limit: int
77
+ truncated: bool
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class SnapshotSettling:
82
+ requested_seconds: float
83
+ maximum_seconds: float
84
+ actual_seconds: float
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class SnapshotCompleteness:
89
+ is_complete: bool
90
+ limitations: tuple[SnapshotLimitation, ...]
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class BrokerSnapshot:
95
+ broker: SnapshotBrokerIdentity
96
+ connection_status: str
97
+ captured_at: datetime
98
+ connected_at: datetime | None
99
+ observation_started_at: datetime | None
100
+ observed_for_seconds: float | None
101
+ topic_filter: str
102
+ topics: tuple[SnapshotTopicState, ...]
103
+ dropped_message_count: int
104
+ freshness: SnapshotFreshness
105
+ results: SnapshotResultLimit
106
+ settling: SnapshotSettling
107
+ completeness: SnapshotCompleteness
@@ -0,0 +1,20 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class McpPreflightCheck:
7
+ name: str
8
+ status: str
9
+ detail: str
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class McpSetupInformation:
14
+ version: str
15
+ executable_path: Path
16
+ data_path: Path
17
+ database_path: Path
18
+ command: str
19
+ command_prefix_arguments: tuple[str, ...]
20
+
File without changes
@@ -0,0 +1,245 @@
1
+ from dataclasses import replace
2
+ from uuid import UUID
3
+
4
+ from topicgate.app.broker_runtime_state import BrokerRuntimeState
5
+ from topicgate.core.config.app_config import AppConfig
6
+ from topicgate.core.config.mqtt_config import MqttConfig
7
+ from topicgate.core.models.broker_profile import BrokerProfile
8
+ from topicgate.core.models.observer_model import ObserverModel
9
+ from topicgate.core.models.mqtt_observation import (
10
+ MqttObservation,
11
+ ObservationSource,
12
+ )
13
+ from topicgate.core.interfaces.topic_message_store import TopicMessageStore
14
+ from topicgate.core.models.observer_workspace import ObserverWorkspace
15
+ from topicgate.infrastructure.credentials.credential_store import CredentialStore
16
+ from topicgate.infrastructure.database.database_context import DatabaseContext
17
+ from topicgate.infrastructure.repository.broker_config_repository import (
18
+ BrokerConfigRepository,
19
+ )
20
+ from topicgate.infrastructure.repository.broker_repository import BrokerRepository
21
+ from topicgate.infrastructure.repository.subscription_repository import (
22
+ SubscriptionRepository,
23
+ )
24
+ from topicgate.processors.observer_model_processor import ObserverModelProcessor
25
+
26
+
27
+ class BrokerProfileService:
28
+ """Compose broker identity, persisted config, credentials, and runtime state."""
29
+
30
+ def __init__(
31
+ self,
32
+ settings: AppConfig | DatabaseContext | None = None,
33
+ db: DatabaseContext | AppConfig | None = None,
34
+ *,
35
+ credential_store: CredentialStore,
36
+ runtime_state: BrokerRuntimeState | None = None,
37
+ topic_messages: TopicMessageStore | None = None,
38
+ ) -> None:
39
+ if isinstance(settings, DatabaseContext):
40
+ self._db = settings
41
+ supplied_settings = db if isinstance(db, AppConfig) else None
42
+ else:
43
+ self._db = db if isinstance(db, DatabaseContext) else DatabaseContext(
44
+ "sqlite:///:memory:"
45
+ )
46
+ supplied_settings = settings
47
+ self._credentials = credential_store
48
+ self._runtime_state = runtime_state or BrokerRuntimeState()
49
+ self.brokers = BrokerRepository(self._db)
50
+ self.configs = BrokerConfigRepository(self._db)
51
+ self.subscriptions = SubscriptionRepository(self._db)
52
+ self._topic_messages = topic_messages
53
+ self._settings_id = supplied_settings.id if supplied_settings else None
54
+
55
+ identities = self.brokers.list_profiles()
56
+ if not identities:
57
+ initial = supplied_settings.mqtt if supplied_settings else MqttConfig(
58
+ "localhost", 1883, "", ""
59
+ )
60
+ self._create_seed_profile("Default", initial, is_active=True)
61
+ self._create_seed_profile(
62
+ "Local MQTT", MqttConfig("localhost", 1883, "", "")
63
+ )
64
+ elif supplied_settings is not None:
65
+ active_id = self.brokers.get_profile().id
66
+ if self._credentials.get_password(active_id) is None:
67
+ self._store_password(active_id, supplied_settings.mqtt.password)
68
+
69
+ self._settings = (
70
+ supplied_settings
71
+ if supplied_settings is not None and not identities
72
+ else AppConfig(self.get_profile().config, id=self._settings_id)
73
+ )
74
+
75
+ def get_profile(self, profile_id: UUID | None = None) -> BrokerProfile:
76
+ identity = self.brokers.get_profile(profile_id)
77
+ config = self.configs.get(identity.id)
78
+ config = replace(
79
+ config,
80
+ password=self._credentials.get_password(identity.id) or "",
81
+ id=self._runtime_state.get_config_id(identity.id, config.id),
82
+ )
83
+ subscriptions = self.subscriptions.list_for_workspace(identity.workspace_id)
84
+ model = self._runtime_state.get_model(identity.id)
85
+ if model is None:
86
+ model = self._hydrate_observer_model(identity.id, subscriptions)
87
+ workspace = ObserverWorkspace(
88
+ id=identity.workspace_id,
89
+ profile_id=identity.id,
90
+ model=model,
91
+ subscriptions=subscriptions,
92
+ )
93
+ return BrokerProfile(
94
+ id=identity.id,
95
+ name=identity.name,
96
+ config=config,
97
+ workspace_id=identity.workspace_id,
98
+ workspace=workspace,
99
+ )
100
+
101
+ def get_all_profiles(self) -> tuple[BrokerProfile, ...]:
102
+ return tuple(self.get_profile(item.id) for item in self.brokers.list_profiles())
103
+
104
+ def create_profile(self, name: str, config: MqttConfig) -> BrokerProfile:
105
+ name = self.brokers.validate_profile_name(name)
106
+ with self._db.transaction() as session:
107
+ config_id = self.configs.create(config, session=session)
108
+ identity = self.brokers.create_profile(
109
+ name, config_id, session=session
110
+ )
111
+ self._store_password(identity.id, config.password)
112
+ self._runtime_state.set_config_id(identity.id, config.id)
113
+ profile = self.get_profile(identity.id)
114
+ self._runtime_state.set_profile_handle(profile)
115
+ self.save()
116
+ return profile
117
+
118
+ def update_profile(self, profile: BrokerProfile) -> None:
119
+ identity = self.brokers.get_profile(profile.id)
120
+ if (
121
+ profile.workspace.profile_id != profile.id
122
+ or profile.workspace_id != identity.workspace_id
123
+ or profile.workspace.id != identity.workspace_id
124
+ ):
125
+ raise ValueError("The workspace must belong to the broker profile.")
126
+ self.brokers.update_profile_name(profile.id, profile.name)
127
+ self.configs.update(profile.id, profile.config)
128
+ self.subscriptions.replace_all(
129
+ identity.workspace_id, profile.workspace.subscriptions
130
+ )
131
+ self._store_password(profile.id, profile.config.password)
132
+ self._runtime_state.set_config_id(profile.id, profile.config.id)
133
+ self._runtime_state.set_profile_handle(profile)
134
+ if identity.is_active:
135
+ self._settings = AppConfig(profile.config, id=self._settings_id)
136
+ self.save()
137
+
138
+ def delete_profile(self, profile_id: UUID) -> BrokerProfile:
139
+ profile = self._runtime_state.get_profile_handle(profile_id) or self.get_profile(
140
+ profile_id
141
+ )
142
+ self.brokers.delete_profile(profile_id)
143
+ self._delete_password(profile_id)
144
+ self._runtime_state.remove(profile_id)
145
+ self.save()
146
+ return profile
147
+
148
+ def select_active_profile(self, profile_id: UUID) -> None:
149
+ self.brokers.select_active_profile(profile_id)
150
+ self._settings = AppConfig(self.get_profile().config, id=self._settings_id)
151
+
152
+ def activate_profile(
153
+ self, profile_id: UUID, config: MqttConfig | None = None
154
+ ) -> None:
155
+ if config is not None:
156
+ self.configs.update(profile_id, config)
157
+ self._store_password(profile_id, config.password)
158
+ self._runtime_state.set_config_id(profile_id, config.id)
159
+ self.select_active_profile(profile_id)
160
+
161
+ def replace_subscriptions(self, workspace_id: UUID, subscriptions) -> None:
162
+ self.subscriptions.replace_all(workspace_id, tuple(subscriptions))
163
+
164
+ def update_observer_workspace(self, workspace: ObserverWorkspace) -> None:
165
+ self.replace_subscriptions(workspace.id, workspace.subscriptions)
166
+
167
+ def update_observer_model(self, model: ObserverModel) -> None:
168
+ self._runtime_state.set_model(self.brokers.get_profile().id, model)
169
+ self.save()
170
+
171
+ def get_observer_model(self) -> ObserverModel:
172
+ return self.get_profile().workspace.model
173
+
174
+ def get_observer_workspace(self) -> ObserverWorkspace:
175
+ return self.get_profile().workspace
176
+
177
+ def get_mqtt(self) -> MqttConfig:
178
+ return self.get_profile().config
179
+
180
+ def update_mqtt(self, config: MqttConfig) -> None:
181
+ profile_id = self.brokers.get_profile().id
182
+ self.configs.update(profile_id, config)
183
+ self._store_password(profile_id, config.password)
184
+ self._runtime_state.set_config_id(profile_id, config.id)
185
+ self._settings = AppConfig(config, id=self._settings_id)
186
+ self.save()
187
+
188
+ def get(self) -> AppConfig:
189
+ current = self.get_mqtt()
190
+ if self._settings.mqtt != current:
191
+ self._settings = AppConfig(current, id=self._settings_id)
192
+ return self._settings
193
+
194
+ def update(self, settings: AppConfig) -> None:
195
+ self.update_mqtt(settings.mqtt)
196
+ self._settings = settings
197
+
198
+ def save(self) -> None:
199
+ """Compatibility hook; repositories commit each mutation."""
200
+
201
+ def _create_seed_profile(
202
+ self, name: str, config: MqttConfig, *, is_active: bool = False
203
+ ) -> None:
204
+ with self._db.transaction() as session:
205
+ config_id = self.configs.create(config, session=session)
206
+ identity = self.brokers.create_profile(
207
+ name, config_id, is_active=is_active, session=session
208
+ )
209
+ self._store_password(identity.id, config.password)
210
+ self._runtime_state.set_config_id(identity.id, config.id)
211
+
212
+ def _hydrate_observer_model(self, broker_id: UUID, subscriptions) -> ObserverModel:
213
+ model = ObserverModelProcessor.add_topics(
214
+ ObserverModel(root_stats=[]),
215
+ (item.topic_filter for item in subscriptions),
216
+ )
217
+ if self._topic_messages is None:
218
+ return model
219
+ for message in self._topic_messages.get_latest_messages(broker_id):
220
+ node = ObserverModelProcessor.find_or_create_node(model, message.topic)
221
+ state = MqttObservation(
222
+ name=node.segment,
223
+ topic=message.topic,
224
+ payload=message.payload,
225
+ qos=message.qos,
226
+ retain=message.retain,
227
+ recieved_at=message.received_at,
228
+ payload_size=message.payload_size,
229
+ message_count=message.message_count,
230
+ source=ObservationSource.STORED,
231
+ observation_id=message.observation_id,
232
+ )
233
+ node.state = state
234
+ model.topic_states[message.topic] = state
235
+ return model
236
+
237
+ def _store_password(self, profile_id: UUID, password: str) -> None:
238
+ if password:
239
+ self._credentials.set_password(profile_id, password)
240
+ else:
241
+ self._delete_password(profile_id)
242
+
243
+ def _delete_password(self, profile_id: UUID) -> None:
244
+ if self._credentials.get_password(profile_id) is not None:
245
+ self._credentials.delete_password(profile_id)
@@ -0,0 +1,58 @@
1
+ from uuid import UUID
2
+
3
+ from topicgate.app.topicgate_runtime import TopicGateRuntime
4
+ from topicgate.core.models.broker_summary import BrokerSummary
5
+
6
+
7
+ class BrokerNotFoundError(LookupError):
8
+ """Raised when a broker selector does not match a configured broker."""
9
+
10
+
11
+ class AmbiguousBrokerError(ValueError):
12
+ """Raised when a broker name matches more than one configured broker."""
13
+
14
+
15
+ class BrokerResolver:
16
+ """Resolve broker summaries by UUID or case-insensitive profile name."""
17
+
18
+ def __init__(self, runtime: TopicGateRuntime) -> None:
19
+ self._runtime = runtime
20
+
21
+ def resolve(self, selector: UUID | str) -> BrokerSummary:
22
+ brokers = self._runtime.list_brokers()
23
+ broker_id = self._parse_uuid(selector)
24
+ if broker_id is not None:
25
+ match = next((item for item in brokers if item.id == broker_id), None)
26
+ if match is None:
27
+ raise BrokerNotFoundError(f"Unknown broker UUID: {broker_id}")
28
+ return match
29
+
30
+ name = str(selector).strip()
31
+ matches = tuple(
32
+ item for item in brokers if item.name.casefold() == name.casefold()
33
+ )
34
+ if not matches:
35
+ raise BrokerNotFoundError(f"Unknown broker name: {name!r}")
36
+ if len(matches) > 1:
37
+ matching_ids = ", ".join(str(item.id) for item in matches)
38
+ raise AmbiguousBrokerError(
39
+ f"Broker name {name!r} is ambiguous; matching UUIDs: {matching_ids}"
40
+ )
41
+ return matches[0]
42
+
43
+ def resolve_or_active(
44
+ self,
45
+ selector: UUID | str | None,
46
+ ) -> BrokerSummary:
47
+ if selector is None:
48
+ return self._runtime.active_broker
49
+ return self.resolve(selector)
50
+
51
+ @staticmethod
52
+ def _parse_uuid(selector: UUID | str) -> UUID | None:
53
+ if isinstance(selector, UUID):
54
+ return selector
55
+ try:
56
+ return UUID(selector.strip())
57
+ except (AttributeError, ValueError):
58
+ return None