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,13 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from topicgate.core.models.topic_message import TopicMessage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TopicMessageStore(ABC):
|
|
8
|
+
"""Read and persist the latest observed state for broker topics."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def get_latest_messages(self, broker_id: UUID) -> tuple[TopicMessage, ...]:
|
|
12
|
+
"""Return the latest stored state for each topic owned by a broker."""
|
|
13
|
+
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from topicgate.core.config.mqtt_config import MqttConfig
|
|
5
|
+
from topicgate.core.models.observer_workspace import ObserverWorkspace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class BrokerProfile:
|
|
10
|
+
id: UUID
|
|
11
|
+
name: str
|
|
12
|
+
config: MqttConfig
|
|
13
|
+
|
|
14
|
+
workspace_id: UUID
|
|
15
|
+
workspace: ObserverWorkspace
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class BrokerProfileIdentity:
|
|
7
|
+
"""Persisted identity and display metadata for a broker profile."""
|
|
8
|
+
|
|
9
|
+
id: UUID
|
|
10
|
+
name: str
|
|
11
|
+
position: int
|
|
12
|
+
is_active: bool
|
|
13
|
+
workspace_id: UUID
|
|
14
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from topicgate.core.config.mqtt_config import MqttConfig
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class BrokerSummary:
|
|
9
|
+
"""Broker settings safe to expose outside the application runtime."""
|
|
10
|
+
|
|
11
|
+
id: UUID
|
|
12
|
+
name: str
|
|
13
|
+
config: MqttConfig
|
|
14
|
+
password_configured: bool
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class MqttMessage:
|
|
6
|
+
topic: str
|
|
7
|
+
payload: bytes
|
|
8
|
+
qos: int
|
|
9
|
+
retain: bool
|
|
10
|
+
payload_size: int | None = None
|
|
11
|
+
|
|
12
|
+
def __post_init__(self) -> None:
|
|
13
|
+
self.payload_size = max(self.payload_size or 0, len(self.payload))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ObservationSource(StrEnum):
|
|
8
|
+
"""How an observation entered the current application process."""
|
|
9
|
+
|
|
10
|
+
STORED = "stored"
|
|
11
|
+
LIVE = "live"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class MqttObservation:
|
|
16
|
+
"""The latest MQTT value observed for one topic."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
topic: str
|
|
20
|
+
payload: bytes
|
|
21
|
+
qos: int
|
|
22
|
+
retain: bool
|
|
23
|
+
recieved_at: datetime
|
|
24
|
+
message_count: int = 1
|
|
25
|
+
payload_size: int | None = None
|
|
26
|
+
source: ObservationSource = ObservationSource.LIVE
|
|
27
|
+
observation_id: UUID | None = None
|
|
28
|
+
connection_id: UUID | None = None
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
self.payload_size = max(self.payload_size or 0, len(self.payload))
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def received_at(self) -> datetime:
|
|
35
|
+
"""Return the receive time with its correctly spelled public name."""
|
|
36
|
+
return self.recieved_at
|
|
@@ -0,0 +1,135 @@
|
|
|
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.observation_deletion_preview import (
|
|
7
|
+
ObservationDeletionEntry,
|
|
8
|
+
ObservationDeletionPreview,
|
|
9
|
+
)
|
|
10
|
+
from topicgate.core.models.observation_retention_policy import (
|
|
11
|
+
ObservationRetentionPolicy,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RetentionRemovalReason(StrEnum):
|
|
16
|
+
EXPIRED = "expired"
|
|
17
|
+
PER_BROKER_ENTRY_LIMIT = "per_broker_entry_limit"
|
|
18
|
+
PER_BROKER_BYTE_LIMIT = "per_broker_byte_limit"
|
|
19
|
+
GLOBAL_ENTRY_LIMIT = "global_entry_limit"
|
|
20
|
+
GLOBAL_BYTE_LIMIT = "global_byte_limit"
|
|
21
|
+
UNSUBSCRIBED = "unsubscribed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class BrokerCacheUsage:
|
|
26
|
+
broker_id: UUID
|
|
27
|
+
entry_count: int
|
|
28
|
+
stored_payload_bytes: int
|
|
29
|
+
oldest_received_at: datetime | None
|
|
30
|
+
newest_received_at: datetime | None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class CacheUsageSummary:
|
|
35
|
+
brokers: tuple[BrokerCacheUsage, ...]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def entry_count(self) -> int:
|
|
39
|
+
return sum(item.entry_count for item in self.brokers)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def stored_payload_bytes(self) -> int:
|
|
43
|
+
return sum(item.stored_payload_bytes for item in self.brokers)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def oldest_received_at(self) -> datetime | None:
|
|
47
|
+
return min(
|
|
48
|
+
(
|
|
49
|
+
item.oldest_received_at
|
|
50
|
+
for item in self.brokers
|
|
51
|
+
if item.oldest_received_at is not None
|
|
52
|
+
),
|
|
53
|
+
default=None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def newest_received_at(self) -> datetime | None:
|
|
58
|
+
return max(
|
|
59
|
+
(
|
|
60
|
+
item.newest_received_at
|
|
61
|
+
for item in self.brokers
|
|
62
|
+
if item.newest_received_at is not None
|
|
63
|
+
),
|
|
64
|
+
default=None,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class PersistedTopicSummary:
|
|
70
|
+
broker_id: UUID
|
|
71
|
+
topic: str
|
|
72
|
+
observation_id: UUID
|
|
73
|
+
stored_payload_bytes: int
|
|
74
|
+
received_at: datetime
|
|
75
|
+
is_subscribed: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class RetentionImpactGroup:
|
|
80
|
+
broker_id: UUID
|
|
81
|
+
reason: RetentionRemovalReason
|
|
82
|
+
entries: tuple[ObservationDeletionEntry, ...]
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def stored_payload_bytes(self) -> int:
|
|
86
|
+
return sum(item.stored_payload_bytes for item in self.entries)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class RetentionPolicyPreview:
|
|
91
|
+
previous_policy: ObservationRetentionPolicy
|
|
92
|
+
proposed_policy: ObservationRetentionPolicy
|
|
93
|
+
deletion: ObservationDeletionPreview
|
|
94
|
+
groups: tuple[RetentionImpactGroup, ...]
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def has_deletions(self) -> bool:
|
|
98
|
+
return bool(self.deletion.entries)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class ObservationDeletionResult:
|
|
103
|
+
previewed_entries: tuple[ObservationDeletionEntry, ...]
|
|
104
|
+
deleted_entries: tuple[ObservationDeletionEntry, ...]
|
|
105
|
+
skipped_entries: tuple[ObservationDeletionEntry, ...]
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def previewed_count(self) -> int:
|
|
109
|
+
return len(self.previewed_entries)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def deleted_count(self) -> int:
|
|
113
|
+
return len(self.deleted_entries)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def skipped_count(self) -> int:
|
|
117
|
+
return len(self.skipped_entries)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def deleted_bytes(self) -> int:
|
|
121
|
+
return sum(item.stored_payload_bytes for item in self.deleted_entries)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def skipped_bytes(self) -> int:
|
|
125
|
+
return sum(item.stored_payload_bytes for item in self.skipped_entries)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_partial(self) -> bool:
|
|
129
|
+
return bool(self.skipped_entries)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class RetentionPolicyApplicationResult:
|
|
134
|
+
policy: ObservationRetentionPolicy
|
|
135
|
+
enforcement: ObservationDeletionResult
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class ObservationDeletionEntry:
|
|
8
|
+
broker_id: UUID
|
|
9
|
+
topic: str
|
|
10
|
+
observation_id: UUID
|
|
11
|
+
received_at: datetime
|
|
12
|
+
stored_payload_bytes: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ObservationDeletionPreview:
|
|
17
|
+
broker_id: UUID | None
|
|
18
|
+
entries: tuple[ObservationDeletionEntry, ...]
|
|
19
|
+
scope: str = "broker"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def broker_ids(self) -> tuple[UUID, ...]:
|
|
23
|
+
return tuple(sorted({entry.broker_id for entry in self.entries}, key=str))
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def total_entries(self) -> int:
|
|
27
|
+
return len(self.entries)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def stored_payload_bytes(self) -> int:
|
|
31
|
+
return sum(entry.stored_payload_bytes for entry in self.entries)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def oldest_received_at(self) -> datetime | None:
|
|
35
|
+
return min((entry.received_at for entry in self.entries), default=None)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def newest_received_at(self) -> datetime | None:
|
|
39
|
+
return max((entry.received_at for entry in self.entries), default=None)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class ObservationRetentionPolicy:
|
|
6
|
+
"""Application-wide limits for persisted MQTT observations."""
|
|
7
|
+
|
|
8
|
+
max_entries_per_broker: int = 1_000
|
|
9
|
+
max_entries_total: int = 10_000
|
|
10
|
+
warning_threshold: float = 0.80
|
|
11
|
+
max_payload_bytes_per_topic: int = 64 * 1024
|
|
12
|
+
max_payload_bytes_per_broker: int = 8 * 1024 * 1024
|
|
13
|
+
max_persisted_payload_database_bytes_total: int = 256 * 1024 * 1024
|
|
14
|
+
max_age_seconds: int | None = None
|
|
15
|
+
auto_remove_expired: bool = True
|
|
16
|
+
auto_remove_excess: bool = True
|
|
17
|
+
auto_remove_unsubscribed: bool = False
|
|
18
|
+
|
|
19
|
+
def __post_init__(self) -> None:
|
|
20
|
+
positive_limits = {
|
|
21
|
+
"max_entries_per_broker": self.max_entries_per_broker,
|
|
22
|
+
"max_entries_total": self.max_entries_total,
|
|
23
|
+
"max_payload_bytes_per_topic": self.max_payload_bytes_per_topic,
|
|
24
|
+
"max_payload_bytes_per_broker": self.max_payload_bytes_per_broker,
|
|
25
|
+
"max_persisted_payload_database_bytes_total": (
|
|
26
|
+
self.max_persisted_payload_database_bytes_total
|
|
27
|
+
),
|
|
28
|
+
}
|
|
29
|
+
for name, value in positive_limits.items():
|
|
30
|
+
if type(value) is not int or value <= 0:
|
|
31
|
+
raise ValueError(f"{name} must be a positive integer.")
|
|
32
|
+
if (
|
|
33
|
+
not isinstance(self.warning_threshold, (int, float))
|
|
34
|
+
or isinstance(self.warning_threshold, bool)
|
|
35
|
+
or not 0 < self.warning_threshold <= 1
|
|
36
|
+
):
|
|
37
|
+
raise ValueError("warning_threshold must be greater than 0 and at most 1.")
|
|
38
|
+
if self.max_age_seconds is not None and (
|
|
39
|
+
type(self.max_age_seconds) is not int or self.max_age_seconds <= 0
|
|
40
|
+
):
|
|
41
|
+
raise ValueError("max_age_seconds must be a positive integer or None.")
|
|
42
|
+
for name in (
|
|
43
|
+
"auto_remove_expired",
|
|
44
|
+
"auto_remove_excess",
|
|
45
|
+
"auto_remove_unsubscribed",
|
|
46
|
+
):
|
|
47
|
+
if type(getattr(self, name)) is not bool:
|
|
48
|
+
raise ValueError(f"{name} must be a boolean.")
|
|
49
|
+
if self.max_entries_per_broker > self.max_entries_total:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"max_entries_per_broker cannot exceed max_entries_total."
|
|
52
|
+
)
|
|
53
|
+
if self.max_payload_bytes_per_topic > self.max_payload_bytes_per_broker:
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"max_payload_bytes_per_topic cannot exceed "
|
|
56
|
+
"max_payload_bytes_per_broker."
|
|
57
|
+
)
|
|
58
|
+
if (
|
|
59
|
+
self.max_payload_bytes_per_broker
|
|
60
|
+
> self.max_persisted_payload_database_bytes_total
|
|
61
|
+
):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"max_payload_bytes_per_broker cannot exceed "
|
|
64
|
+
"max_persisted_payload_database_bytes_total."
|
|
65
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from topicgate.core.models.mqtt_observation import MqttObservation
|
|
4
|
+
from topicgate.core.models.mqtt_state import MqttState
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Check compatibility for callers migrating from the former presentation name.
|
|
8
|
+
TopicState = MqttObservation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TopicNode:
|
|
13
|
+
segment: str
|
|
14
|
+
state: MqttObservation | None = None
|
|
15
|
+
children: dict[str, "TopicNode"] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ObserverModel(MqttState):
|
|
20
|
+
root_stats: list[TopicNode]
|
|
21
|
+
topic_states: dict[str, MqttObservation] = field(default_factory=dict)
|
|
22
|
+
configured_topics: set[str] = field(default_factory=set)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from topicgate.core.models.observer_model import ObserverModel
|
|
5
|
+
from topicgate.core.models.subscription import Subscription
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ObserverWorkspace:
|
|
10
|
+
id: UUID
|
|
11
|
+
profile_id: UUID
|
|
12
|
+
model: ObserverModel
|
|
13
|
+
subscriptions: tuple[Subscription, ...] = ()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from topicgate.core.mqtt_topics import validate_topic_size_and_depth
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Subscription:
|
|
8
|
+
"""Editable MQTT subscription options."""
|
|
9
|
+
|
|
10
|
+
topic_filter: str
|
|
11
|
+
qos: int = 1
|
|
12
|
+
retain_as_published: bool = False
|
|
13
|
+
retain_handling: int = 0
|
|
14
|
+
id: int | None = None
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
segments = validate_topic_size_and_depth(
|
|
18
|
+
self.topic_filter, "topic filter"
|
|
19
|
+
)
|
|
20
|
+
if any("#" in segment and segment != "#" for segment in segments):
|
|
21
|
+
raise ValueError("# must occupy an entire topic level.")
|
|
22
|
+
if "#" in segments and segments[-1] != "#":
|
|
23
|
+
raise ValueError("# is only valid in the final topic level.")
|
|
24
|
+
if any("+" in segment and segment != "+" for segment in segments):
|
|
25
|
+
raise ValueError("+ must occupy an entire topic level.")
|
|
26
|
+
if self.qos not in (0, 1, 2):
|
|
27
|
+
raise ValueError("QoS must be 0, 1, or 2.")
|
|
28
|
+
if self.retain_handling not in (0, 1, 2):
|
|
29
|
+
raise ValueError("Retain handling must be 0, 1, or 2.")
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from uuid import UUID
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class TopicMessage:
|
|
8
|
+
broker_id: UUID
|
|
9
|
+
topic: str
|
|
10
|
+
payload: bytes
|
|
11
|
+
qos: int
|
|
12
|
+
retain: bool
|
|
13
|
+
received_at: datetime
|
|
14
|
+
payload_size: int
|
|
15
|
+
message_count: int
|
|
16
|
+
observation_id: UUID
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
MAX_MQTT_TOPIC_BYTES = 65_535
|
|
2
|
+
MAX_MQTT_TOPIC_LEVELS = 128
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def validate_topic_size_and_depth(topic: str, kind: str) -> list[str]:
|
|
6
|
+
if not topic:
|
|
7
|
+
raise ValueError(f"A {kind} is required.")
|
|
8
|
+
if "\0" in topic:
|
|
9
|
+
raise ValueError(f"A {kind} cannot contain a null character.")
|
|
10
|
+
if len(topic.encode("utf-8")) > MAX_MQTT_TOPIC_BYTES:
|
|
11
|
+
raise ValueError(
|
|
12
|
+
f"A {kind} cannot exceed {MAX_MQTT_TOPIC_BYTES:,} UTF-8 bytes."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
segments = topic.split("/")
|
|
16
|
+
if len(segments) > MAX_MQTT_TOPIC_LEVELS:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
f"A {kind} cannot exceed {MAX_MQTT_TOPIC_LEVELS} levels."
|
|
19
|
+
)
|
|
20
|
+
return segments
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_topic_name(topic: str) -> list[str]:
|
|
24
|
+
segments = validate_topic_size_and_depth(topic, "topic name")
|
|
25
|
+
if "+" in topic or "#" in topic:
|
|
26
|
+
raise ValueError("A topic name cannot contain wildcard characters.")
|
|
27
|
+
return segments
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def mqtt_filter_matches(topic_filter: str, topic: str) -> bool:
|
|
31
|
+
"""Return whether an MQTT topic matches a valid wildcard filter."""
|
|
32
|
+
filter_segments = topic_filter.split("/")
|
|
33
|
+
topic_segments = topic.split("/")
|
|
34
|
+
|
|
35
|
+
if topic.startswith("$") and not topic_filter.startswith("$"):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
for index, filter_segment in enumerate(filter_segments):
|
|
39
|
+
if filter_segment == "#":
|
|
40
|
+
return index == len(filter_segments) - 1
|
|
41
|
+
if index >= len(topic_segments):
|
|
42
|
+
return False
|
|
43
|
+
if filter_segment != "+" and filter_segment != topic_segments[index]:
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
return len(filter_segments) == len(topic_segments)
|
|
File without changes
|
topicgate/gui/app.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ctypes
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import QCoreApplication
|
|
8
|
+
from PySide6.QtGui import QGuiApplication, QIcon
|
|
9
|
+
from PySide6.QtWidgets import QApplication
|
|
10
|
+
from qasync import QEventLoop
|
|
11
|
+
|
|
12
|
+
from topicgate.app.app_dependencies import AppDependencies
|
|
13
|
+
from topicgate.app.services.service_container import ServiceContainer
|
|
14
|
+
from topicgate.gui.main_window import MainWindow
|
|
15
|
+
from topicgate.gui.main_view_model import MainViewModel
|
|
16
|
+
from topicgate.gui.theme import apply_light_theme
|
|
17
|
+
from topicgate.paths import asset_path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class App:
|
|
21
|
+
def __init__(self, qt_application: QApplication):
|
|
22
|
+
self._qt_application = qt_application
|
|
23
|
+
self._dependencies = AppDependencies(control_owner="desktop")
|
|
24
|
+
self._services = ServiceContainer(self._dependencies)
|
|
25
|
+
self._view_model = MainViewModel(
|
|
26
|
+
runtime=self._dependencies.runtime,
|
|
27
|
+
snapshot_service=self._dependencies.snapshot_service,
|
|
28
|
+
mcp_setup_service=self._dependencies.mcp_setup,
|
|
29
|
+
)
|
|
30
|
+
self._window = MainWindow(self._view_model)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def run(self) -> int:
|
|
34
|
+
stopped = asyncio.get_running_loop().create_future()
|
|
35
|
+
|
|
36
|
+
def request_shutdown() -> None:
|
|
37
|
+
if not stopped.done():
|
|
38
|
+
stopped.set_result(None)
|
|
39
|
+
|
|
40
|
+
self._qt_application.lastWindowClosed.connect(request_shutdown)
|
|
41
|
+
try:
|
|
42
|
+
try:
|
|
43
|
+
await self._services.start_services()
|
|
44
|
+
except ConnectionError as error:
|
|
45
|
+
self._view_model.log_message.emit(
|
|
46
|
+
f"Initial MQTT connection failed: {error}"
|
|
47
|
+
)
|
|
48
|
+
await self._view_model.start()
|
|
49
|
+
self._window.show()
|
|
50
|
+
await stopped
|
|
51
|
+
return 0
|
|
52
|
+
finally:
|
|
53
|
+
try:
|
|
54
|
+
try:
|
|
55
|
+
cancel_operations = getattr(
|
|
56
|
+
self._window,
|
|
57
|
+
"cancel_pending_operations",
|
|
58
|
+
None,
|
|
59
|
+
)
|
|
60
|
+
if cancel_operations is not None:
|
|
61
|
+
await cancel_operations()
|
|
62
|
+
finally:
|
|
63
|
+
await self._view_model.stop()
|
|
64
|
+
finally:
|
|
65
|
+
await self._services.stop_services()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def configure_windows_identity() -> None:
|
|
69
|
+
if sys.platform == "win32":
|
|
70
|
+
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
|
71
|
+
"Dumdart.TopicGate"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def configure_application_identity() -> None:
|
|
76
|
+
QCoreApplication.setOrganizationName("Dumdart")
|
|
77
|
+
QCoreApplication.setApplicationName("TopicGate")
|
|
78
|
+
QGuiApplication.setApplicationDisplayName("TopicGate Desktop")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run() -> int:
|
|
82
|
+
configure_windows_identity()
|
|
83
|
+
configure_application_identity()
|
|
84
|
+
|
|
85
|
+
qt_application = QApplication(sys.argv)
|
|
86
|
+
icon = QIcon(asset_path("icon.png"))
|
|
87
|
+
qt_application.setWindowIcon(icon)
|
|
88
|
+
|
|
89
|
+
apply_light_theme(qt_application)
|
|
90
|
+
|
|
91
|
+
event_loop = QEventLoop(qt_application)
|
|
92
|
+
asyncio.set_event_loop(event_loop)
|
|
93
|
+
|
|
94
|
+
app = App(qt_application)
|
|
95
|
+
|
|
96
|
+
with event_loop:
|
|
97
|
+
return event_loop.run_until_complete(app.run())
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
|
|
7
|
+
|
|
8
|
+
class BrokerStateReader(Protocol):
|
|
9
|
+
"""Provides settings for the active broker profile to the UI."""
|
|
10
|
+
|
|
11
|
+
def get_mqtt(self) -> MqttConfig: ...
|
|
12
|
+
|
|
13
|
+
def update_mqtt(self, mqtt: MqttConfig) -> None: ...
|
|
14
|
+
|
|
15
|
+
def get_profile(self, profile_id: UUID | None = None) -> BrokerProfile: ...
|
|
16
|
+
|
|
17
|
+
def get_all_profiles(self) -> tuple[BrokerProfile, ...]: ...
|
|
18
|
+
|
|
19
|
+
def create_profile(self, name: str, config: MqttConfig) -> BrokerProfile: ...
|
|
20
|
+
|
|
21
|
+
def update_profile(self, profile: BrokerProfile) -> None: ...
|
|
22
|
+
|
|
23
|
+
def delete_profile(self, profile_id: UUID) -> BrokerProfile: ...
|
|
24
|
+
|
|
25
|
+
def activate_profile(
|
|
26
|
+
self,
|
|
27
|
+
profile_id: UUID,
|
|
28
|
+
mqtt: MqttConfig | None = None,
|
|
29
|
+
) -> None: ...
|