ironflock 1.0.9__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,25 +1,27 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ironflock
3
- Version: 1.0.9
4
- Summary: SDK to integrate your IronFlock Industry 4 Apps with the IronFlock Data Infrastructure
5
- Home-page: https://github.com/RecordEvolution/ironflock-py
6
- Author: Record Evolution GmbH
7
- Author-email: marko.petzold@record-evolution.de
8
- License: MIT
9
- Requires-Python: >=3.8
10
- Description-Content-Type: text/markdown
3
+ Version: 1.2.0
4
+ Summary: IronFlock Python SDK for connecting to the IronFlock Platform
5
+ License-Expression: MIT
11
6
  License-File: LICENSE
12
- Requires-Dist: autobahn[asyncio,serialization]==22.3.2
13
- Dynamic: author
14
- Dynamic: author-email
15
- Dynamic: description
16
- Dynamic: description-content-type
17
- Dynamic: home-page
18
- Dynamic: license
19
- Dynamic: license-file
20
- Dynamic: requires-dist
21
- Dynamic: requires-python
22
- Dynamic: summary
7
+ Author: Marko Petzold, IronFlock GmbH
8
+ Author-email: info@ironflock.com
9
+ Requires-Python: >=3.9
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
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
24
+ Description-Content-Type: text/markdown
23
25
 
24
26
  # ironflock
25
27
 
@@ -32,6 +34,19 @@ of the device's fleet and the data is collected in the respective fleet database
32
34
  So if you use the library in your app, the data collection will always be private to the app user's fleet.
33
35
 
34
36
  For more information on the IronFlock IoT Devops Platform for engineers and developers visit our [IronFlock](https://www.ironflock.com) home page.
37
+
38
+ ## Requirements
39
+
40
+ - Python 3.8 or higher
41
+ - Compatible with Python 3.8, 3.9, 3.10, 3.11, and 3.12
42
+
43
+ ## Installation
44
+
45
+ Install from PyPI:
46
+
47
+ ```shell
48
+ pip install ironflock
49
+ ```
35
50
  ## Usage
36
51
 
37
52
  ```python
@@ -75,10 +90,10 @@ the [examples](https://github.com/RecordEvolution/ironflock-py/tree/main/example
75
90
 
76
91
  ## Development
77
92
 
78
- Install the necessary components if you don't have them already:
93
+ Install the necessary build tools if you don't have them already:
79
94
 
80
95
  ```shell
81
- pip install --upgrade setuptools wheel twine
96
+ pip install --upgrade build twine
82
97
  ```
83
98
 
84
99
  Build and publish a new pypi package:
@@ -87,4 +102,17 @@ Build and publish a new pypi package:
87
102
  just publish
88
103
  ```
89
104
 
105
+ Alternatively, you can build manually:
106
+
107
+ ```shell
108
+ # Clean previous builds
109
+ rm -rf build dist *.egg-info
110
+
111
+ # Build the package
112
+ python -m build
113
+
114
+ # Upload to PyPI
115
+ twine upload dist/*
116
+ ```
117
+
90
118
  Check the package at https://pypi.org/project/ironflock/.
@@ -9,6 +9,19 @@ of the device's fleet and the data is collected in the respective fleet database
9
9
  So if you use the library in your app, the data collection will always be private to the app user's fleet.
10
10
 
11
11
  For more information on the IronFlock IoT Devops Platform for engineers and developers visit our [IronFlock](https://www.ironflock.com) home page.
12
+
13
+ ## Requirements
14
+
15
+ - Python 3.8 or higher
16
+ - Compatible with Python 3.8, 3.9, 3.10, 3.11, and 3.12
17
+
18
+ ## Installation
19
+
20
+ Install from PyPI:
21
+
22
+ ```shell
23
+ pip install ironflock
24
+ ```
12
25
  ## Usage
13
26
 
14
27
  ```python
@@ -52,10 +65,10 @@ the [examples](https://github.com/RecordEvolution/ironflock-py/tree/main/example
52
65
 
53
66
  ## Development
54
67
 
55
- Install the necessary components if you don't have them already:
68
+ Install the necessary build tools if you don't have them already:
56
69
 
57
70
  ```shell
58
- pip install --upgrade setuptools wheel twine
71
+ pip install --upgrade build twine
59
72
  ```
60
73
 
61
74
  Build and publish a new pypi package:
@@ -64,4 +77,17 @@ Build and publish a new pypi package:
64
77
  just publish
65
78
  ```
66
79
 
80
+ Alternatively, you can build manually:
81
+
82
+ ```shell
83
+ # Clean previous builds
84
+ rm -rf build dist *.egg-info
85
+
86
+ # Build the package
87
+ python -m build
88
+
89
+ # Upload to PyPI
90
+ twine upload dist/*
91
+ ```
92
+
67
93
  Check the package at https://pypi.org/project/ironflock/.
@@ -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"