ironflock 1.1.0__tar.gz → 1.2.0__tar.gz

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.
@@ -1,24 +1,27 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ironflock
3
- Version: 1.1.0
4
- Summary: SDK to integrate your IronFlock Industry 4 Apps with the IronFlock Data Infrastructure
5
- Author-email: Record Evolution GmbH <marko.petzold@record-evolution.de>
3
+ Version: 1.2.0
4
+ Summary: IronFlock Python SDK for connecting to the IronFlock Platform
6
5
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/RecordEvolution/ironflock-py
8
- Project-URL: Repository, https://github.com/RecordEvolution/ironflock-py
6
+ License-File: LICENSE
7
+ Author: Marko Petzold, IronFlock GmbH
8
+ Author-email: info@ironflock.com
9
+ Requires-Python: >=3.9
9
10
  Classifier: Development Status :: 5 - Production/Stable
10
11
  Classifier: Intended Audience :: Developers
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.8
13
- Classifier: Programming Language :: Python :: 3.9
14
- Classifier: Programming Language :: Python :: 3.10
15
- Classifier: Programming Language :: Python :: 3.11
16
- Classifier: Programming Language :: Python :: 3.12
17
- Requires-Python: >=3.8
12
+ Provides-Extra: dev
13
+ Provides-Extra: docs
14
+ Requires-Dist: autobahn[asyncio,serialization] (==24.4.2)
15
+ Requires-Dist: mypy ; extra == "dev"
16
+ Requires-Dist: pydantic ; extra == "dev"
17
+ Requires-Dist: pytest ; extra == "dev"
18
+ Requires-Dist: ruff ; extra == "dev"
19
+ Requires-Dist: sphinx (>=7) ; extra == "docs"
20
+ Requires-Dist: sphinx-autodoc-typehints (>=2.0) ; extra == "docs"
21
+ Requires-Dist: sphinx-book-theme (>=1.1.4) ; extra == "docs"
22
+ Project-URL: Homepage, https://github.com/RecordEvolution/ironflock-py
23
+ Project-URL: Repository, https://github.com/RecordEvolution/ironflock-py
18
24
  Description-Content-Type: text/markdown
19
- License-File: LICENSE
20
- Requires-Dist: autobahn[asyncio,serialization]==24.4.2
21
- Dynamic: license-file
22
25
 
23
26
  # ironflock
24
27
 
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "ironflock"
3
+ version = "1.2.0"
4
+ description = "IronFlock Python SDK for connecting to the IronFlock Platform"
5
+ authors = [
6
+ {name = "Marko Petzold, IronFlock GmbH", email = "info@ironflock.com"}
7
+ ]
8
+ license = "MIT"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ classifiers = [
12
+ "Development Status :: 5 - Production/Stable",
13
+ "Intended Audience :: Developers",
14
+ ]
15
+ dependencies = [
16
+ "autobahn[asyncio,serialization]==24.4.2"
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://github.com/RecordEvolution/ironflock-py"
21
+ Repository = "https://github.com/RecordEvolution/ironflock-py"
22
+
23
+ [project.optional-dependencies]
24
+ docs = [
25
+ "sphinx (>=7)",
26
+ "sphinx-book-theme (>=1.1.4)",
27
+ "sphinx-autodoc-typehints (>=2.0)"
28
+ ]
29
+
30
+ dev = [
31
+ "pytest",
32
+ "ruff",
33
+ "pydantic",
34
+ "mypy",
35
+ ]
36
+
37
+
38
+ [build-system]
39
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
40
+ build-backend = "poetry.core.masonry.api"
41
+
42
+ [tool.poetry]
43
+ packages = [{ include = "ironflock", from = "src" }]
@@ -0,0 +1,343 @@
1
+ import asyncio
2
+ import time
3
+ import os
4
+ from typing import Optional, List, Dict, Any, Callable
5
+ from autobahn.asyncio.wamp import ApplicationSession
6
+ from autobahn.asyncio.component import Component
7
+ from autobahn.wamp import auth
8
+ from enum import Enum
9
+
10
+ class Stage(Enum):
11
+ """Stage enumeration for different deployment environments"""
12
+ DEVELOPMENT = "DEV"
13
+ PRODUCTION = "PROD"
14
+
15
+ def getSerialNumber(serial_number: str = None) -> str:
16
+ """Get device serial number from parameter or environment variable"""
17
+ if serial_number is None:
18
+ s_num = os.environ.get("DEVICE_SERIAL_NUMBER")
19
+ if s_num is None:
20
+ raise Exception("ENV Variable 'DEVICE_SERIAL_NUMBER' is not set!")
21
+ else:
22
+ s_num = serial_number
23
+ return s_num
24
+
25
+ DATAPODS_WS_URI = "wss://cbw.datapods.io/ws-ua-usr"
26
+ STUDIO_WS_URI_OLD = "wss://cbw.record-evolution.com/ws-ua-usr"
27
+ STUDIO_WS_URI = "wss://cbw.ironflock.com/ws-ua-usr"
28
+ LOCALHOST_WS_URI = "ws://localhost:8080/ws-ua-usr"
29
+
30
+ socketURIMap = {
31
+ "https://studio.datapods.io": DATAPODS_WS_URI,
32
+ "https://studio.record-evolution.com": STUDIO_WS_URI_OLD,
33
+ "https://studio.ironflock.com": STUDIO_WS_URI,
34
+ "http://localhost:8086": LOCALHOST_WS_URI,
35
+ "http://host.docker.internal:8086": LOCALHOST_WS_URI
36
+ }
37
+
38
+ class CrossbarConnection:
39
+ """
40
+ Python version of the TypeScript CrossbarConnection class.
41
+ Manages WAMP connections with automatic reconnection, subscription management, and session handling.
42
+ """
43
+
44
+ def __init__(self):
45
+ self.session: Optional[ApplicationSession] = None
46
+ self.component: Optional[Component] = None
47
+ self.connection_options: Optional[Dict[str, Any]] = None
48
+ self.subscriptions: List[Any] = [] # List of autobahn subscriptions
49
+ self.registrations: List[Any] = [] # List of autobahn registrations
50
+ self.connection_drop_wait_time: int = 6000 # milliseconds
51
+ self.realm: Optional[str] = None
52
+ self.serial_number: Optional[str] = None
53
+ self._first_connection_future: Optional[asyncio.Future] = None
54
+ self._is_connected = False
55
+ self._session_wait_timeout = 6.0 # seconds
56
+
57
+ @staticmethod
58
+ def getWebSocketURI():
59
+ reswarm_url = os.environ.get("RESWARM_URL")
60
+ if not reswarm_url:
61
+ return STUDIO_WS_URI
62
+ return socketURIMap.get(reswarm_url)
63
+
64
+ async def configure(
65
+ self,
66
+ swarm_key: int,
67
+ app_key: int,
68
+ stage: Stage,
69
+ cburl: Optional[str] = None,
70
+ serial_number: Optional[str] = None
71
+ ):
72
+ """
73
+ Configure the crossbar connection with realm and WAMPCRA authentication details.
74
+
75
+ Args:
76
+ swarm_key: The swarm key identifier
77
+ app_key: The application key identifier
78
+ stage: The deployment stage (development, production)
79
+ cburl: Optional crossbar URL (defaults to environment variable RESWARM_URL mapping)
80
+ serial_number: Optional device serial number (defaults to DEVICE_SERIAL_NUMBER env var)
81
+ """
82
+ self.realm = f"realm-{swarm_key}-{app_key}-{stage.value.lower()}"
83
+ self.serial_number = getSerialNumber(serial_number)
84
+
85
+ # Get URL from environment if not provided
86
+ if cburl is None:
87
+ cburl = self.getWebSocketURI()
88
+
89
+ self.connection_options = {
90
+ 'url': cburl,
91
+ 'realm': self.realm,
92
+ 'authmethods': ['wampcra'],
93
+ 'authid': self.serial_number,
94
+ 'max_retries': -1, # infinite retries
95
+ 'max_retry_delay': 2,
96
+ 'initial_retry_delay': 1,
97
+ 'serializers': ['msgpack']
98
+ }
99
+
100
+ # Create the component with WAMPCRA authentication
101
+ transports = [{
102
+ 'type': 'websocket',
103
+ 'url': cburl,
104
+ 'serializers': ['msgpack']
105
+ }]
106
+
107
+ # Custom session class for WAMPCRA authentication
108
+ class AppSession(ApplicationSession):
109
+ def onConnect(session_self):
110
+ print('onConnect called')
111
+ session_self.join(self.realm, ['wampcra'], self.serial_number)
112
+
113
+ def onChallenge(session_self, challenge):
114
+ print(f'challenge requested for {challenge.method}')
115
+ if challenge.method == "wampcra":
116
+ signature = auth.compute_wcs(
117
+ self.serial_number, challenge.extra["challenge"]
118
+ )
119
+ return signature
120
+ raise Exception(f"Invalid authmethod {challenge.method}")
121
+
122
+ self.component = Component(
123
+ transports=transports,
124
+ realm=self.realm,
125
+ session_factory=AppSession
126
+ )
127
+
128
+ # Set up event handlers
129
+ self.component.on('join', self._on_open)
130
+ self.component.on('leave', self._on_close)
131
+ self.component.on('disconnect', self._on_disconnect)
132
+
133
+ # Create future for first connection
134
+ self._first_connection_future = asyncio.Future()
135
+
136
+ async def _on_open(self, session: ApplicationSession, details):
137
+ """Handle session open event"""
138
+ self.session = session
139
+ self._is_connected = True
140
+ await self._resubscribe_all()
141
+ print(f"Connection to realm '{session._realm}' established")
142
+
143
+ if self._first_connection_future and not self._first_connection_future.done():
144
+ self._first_connection_future.set_result(None)
145
+
146
+ async def _on_close(self, session: ApplicationSession, details):
147
+ """Handle session close event"""
148
+ self.session = None
149
+ self._is_connected = False
150
+ print(f"Connection to realm {self.realm} closed: {details.reason}")
151
+
152
+ if self._first_connection_future and not self._first_connection_future.done():
153
+ self._first_connection_future.set_exception(
154
+ Exception(f"Connection closed: {details.reason}")
155
+ )
156
+
157
+ async def _on_disconnect(self, session: ApplicationSession, was_clean: bool):
158
+ """Handle disconnect event"""
159
+ self.session = None
160
+ self._is_connected = False
161
+ print(f"Disconnected from realm {self.realm}, clean: {was_clean}")
162
+
163
+ async def _session_wait(self) -> None:
164
+ """Wait for session to be available"""
165
+ start_time = time.time()
166
+ while not self.session or not self._is_connected:
167
+ if time.time() - start_time > self._session_wait_timeout:
168
+ raise TimeoutError('Timeout waiting for session')
169
+ await asyncio.sleep(0.2)
170
+
171
+ @property
172
+ def is_open(self) -> bool:
173
+ """Check if the connection is open"""
174
+ return self._is_connected and self.session is not None
175
+
176
+ async def start(self) -> None:
177
+ """Start the connection"""
178
+ if not self.component:
179
+ raise ValueError("Must call configure() before start()")
180
+
181
+ print(f'Starting connection for realm {self.realm}')
182
+
183
+ # Start the component
184
+ await self.component.start()
185
+
186
+ # Wait for first connection
187
+ if self._first_connection_future:
188
+ await self._first_connection_future
189
+
190
+ async def stop(self) -> None:
191
+ """Stop the connection"""
192
+ if self.component:
193
+ await self.component.stop()
194
+ self._is_connected = False
195
+ self.session = None
196
+
197
+ async def call(
198
+ self,
199
+ topic: str,
200
+ args: Optional[List[Any]] = None,
201
+ kwargs: Optional[Dict[str, Any]] = None,
202
+ options: Optional[Dict[str, Any]] = None
203
+ ) -> Any:
204
+ """Call a remote procedure"""
205
+ await self._session_wait()
206
+ if not self.session:
207
+ raise RuntimeError("No active session")
208
+
209
+ args = args or []
210
+ kwargs = kwargs or {}
211
+
212
+ result = await self.session.call(topic, *args, **kwargs, options=options)
213
+ return result
214
+
215
+ async def subscribe(
216
+ self,
217
+ topic: str,
218
+ handler: Callable,
219
+ options: Optional[Dict[str, Any]] = None
220
+ ) -> Optional[Any]:
221
+ """Subscribe to a topic"""
222
+ await self._session_wait()
223
+ if not self.session:
224
+ raise RuntimeError("No active session")
225
+
226
+ subscription = await self.session.subscribe(handler, topic, options=options)
227
+ if subscription:
228
+ self.subscriptions.append(subscription)
229
+ return subscription
230
+
231
+ async def publish(
232
+ self,
233
+ topic: str,
234
+ args: Optional[List[Any]] = None,
235
+ kwargs: Optional[Dict[str, Any]] = None,
236
+ options: Optional[Dict[str, Any]] = None
237
+ ) -> Optional[Any]:
238
+ """Publish to a topic"""
239
+ await self._session_wait()
240
+ if not self.session:
241
+ raise RuntimeError("No active session")
242
+
243
+ args = args or []
244
+ kwargs = kwargs or {}
245
+
246
+ result = await self.session.publish(topic, *args, options=options, **kwargs)
247
+ return result
248
+
249
+ async def unsubscribe(self, subscription: Any) -> None:
250
+ """Unsubscribe from a subscription"""
251
+ await self._session_wait()
252
+ if not self.session:
253
+ raise RuntimeError("No active session")
254
+
255
+ if subscription in self.subscriptions:
256
+ self.subscriptions.remove(subscription)
257
+ await self.session.unsubscribe(subscription)
258
+
259
+ async def unsubscribe_func(
260
+ self,
261
+ topic: str,
262
+ handler: Optional[Callable] = None
263
+ ) -> None:
264
+ """Unsubscribe a specific function from a topic"""
265
+ await self._session_wait()
266
+
267
+ if handler is None:
268
+ return await self.unsubscribe_topic(topic)
269
+
270
+ # Find and remove subscriptions matching topic and handler
271
+ to_remove = []
272
+ for subscription in self.subscriptions:
273
+ if hasattr(subscription, 'topic') and subscription.topic == topic:
274
+ if hasattr(subscription, 'handler') and subscription.handler == handler:
275
+ to_remove.append(subscription)
276
+
277
+ for subscription in to_remove:
278
+ try:
279
+ await self.unsubscribe(subscription)
280
+ except Exception as e:
281
+ print(f"Failed to unsubscribe from {topic}: {e}")
282
+
283
+ async def unsubscribe_topic(self, topic: str) -> bool:
284
+ """Unsubscribe all handlers from a topic"""
285
+ matching_subs = [
286
+ sub for sub in self.subscriptions
287
+ if hasattr(sub, 'topic') and sub.topic == topic
288
+ ]
289
+
290
+ if not matching_subs:
291
+ return True
292
+
293
+ # Unsubscribe all matching subscriptions
294
+ tasks = [self.unsubscribe(sub) for sub in matching_subs]
295
+ await asyncio.gather(*tasks, return_exceptions=True)
296
+ return True
297
+
298
+ async def register(
299
+ self,
300
+ topic: str,
301
+ endpoint: Callable,
302
+ options: Optional[Dict[str, Any]] = None
303
+ ) -> Optional[Any]:
304
+ """Register a procedure"""
305
+ await self._session_wait()
306
+ if not self.session:
307
+ raise RuntimeError("No active session")
308
+
309
+ registration = await self.session.register(endpoint, topic, options=options)
310
+ if registration:
311
+ self.registrations.append(registration)
312
+ return registration
313
+
314
+ async def unregister(self, registration: Any) -> None:
315
+ """Unregister a procedure"""
316
+ await self._session_wait()
317
+ if not self.session:
318
+ raise RuntimeError("No active session")
319
+
320
+ if registration in self.registrations:
321
+ self.registrations.remove(registration)
322
+ await self.session.unregister(registration)
323
+
324
+ async def _resubscribe_all(self) -> None:
325
+ """Resubscribe to all topics after reconnection"""
326
+ if not self.subscriptions:
327
+ return
328
+
329
+ print(f'Resubscribing to {len(self.subscriptions)} subscriptions')
330
+
331
+ # Store current subscriptions and clear the list
332
+ old_subscriptions = self.subscriptions.copy()
333
+ self.subscriptions.clear()
334
+
335
+ # Resubscribe to all topics
336
+ tasks = []
337
+ for old_sub in old_subscriptions:
338
+ if hasattr(old_sub, 'topic') and hasattr(old_sub, 'handler'):
339
+ task = self.subscribe(old_sub.topic, old_sub.handler)
340
+ tasks.append(task)
341
+
342
+ if tasks:
343
+ await asyncio.gather(*tasks, return_exceptions=True)
@@ -0,0 +1,10 @@
1
+ from ironflock.ironflock import IronFlock
2
+ from ironflock.CrossbarConnection import CrossbarConnection, Stage
3
+
4
+ __all__ = [
5
+ "IronFlock",
6
+ "CrossbarConnection",
7
+ "Stage"
8
+ ]
9
+
10
+ __version__ = "1.2.0"