ironflock 1.1.0__py3-none-any.whl → 1.2.1__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.
@@ -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 IronFlock app 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 IronFlock app 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 IronFlock app 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 IronFlock app realm {self.realm}')
182
+
183
+ # Start the component (non-blocking in autobahn asyncio)
184
+ self.component.start()
185
+
186
+ # Wait for first connection to be established
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)
ironflock/__init__.py CHANGED
@@ -1,10 +1,10 @@
1
- from ironflock.AutobahnConnection import (
2
- create_application_component,
3
- create_application_session,
4
- )
5
-
6
1
  from ironflock.ironflock import IronFlock
2
+ from ironflock.CrossbarConnection import CrossbarConnection, Stage
7
3
 
8
- __all__ = ["IronFlock", "create_application_component", "create_application_session"]
4
+ __all__ = [
5
+ "IronFlock",
6
+ "CrossbarConnection",
7
+ "Stage"
8
+ ]
9
9
 
10
- __version__ = "1.0.8"
10
+ __version__ = "1.2.0"
ironflock/ironflock.py CHANGED
@@ -1,29 +1,26 @@
1
1
  import os
2
2
  import asyncio
3
- from typing import Optional
4
- from autobahn.asyncio.component import Component, run
5
- from autobahn.wamp.interfaces import ISession
6
- from autobahn.wamp.types import PublishOptions, RegisterOptions
7
- from autobahn.wamp.request import Publication
3
+ from typing import Optional, Any
8
4
 
9
- from ironflock.AutobahnConnection import getSerialNumber, create_application_component
5
+ from ironflock.CrossbarConnection import CrossbarConnection, Stage, getSerialNumber
6
+ from autobahn.wamp.types import PublishOptions, RegisterOptions, SubscribeOptions, CallOptions
10
7
 
11
8
 
12
9
  class IronFlock:
13
- """Conveniance class for easy-to-use message publishing in the IronFlock platform.
10
+ """Convenience class for easy-to-use message publishing in the IronFlock platform.
14
11
 
15
12
  Example:
16
13
 
17
14
  async def main():
18
15
  while True:
19
- publication = await ironFlock.publish("test.publish.pw", 1, "two", 3, foo="bar")
16
+ publication = await ironflock.publish("test.publish.pw", 1, "two", 3, foo="bar")
20
17
  print(publication)
21
18
  await asyncio.sleep(3)
22
19
 
23
20
 
24
21
  if __name__ == "__main__":
25
22
  ironflock = IronFlock(mainFunc=main)
26
- ironFlock.run()
23
+ await ironflock.run()
27
24
  """
28
25
 
29
26
  def __init__(self, serial_number: str = None, mainFunc=None) -> None:
@@ -32,84 +29,114 @@ class IronFlock:
32
29
  Args:
33
30
  serial_number (str, optional): serial_number of device.
34
31
  Defaults to None, in which case the environment variable DEVICE_SERIAL_NUMBER is used.
32
+ mainFunc (callable, optional): Main function to run after connection is established.
35
33
  """
36
34
  self._serial_number = getSerialNumber(serial_number)
37
35
  self._device_name = os.environ.get("DEVICE_NAME")
38
36
  self._device_key = os.environ.get("DEVICE_KEY")
39
- self._component = create_application_component(serial_number)
40
- self._session: ISession = None
37
+ self._connection = CrossbarConnection()
41
38
  self.mainFunc = mainFunc
42
39
  self._main_task = None
43
-
44
- @self._component.on_join
45
- async def onJoin(session, details):
46
- print("component joined")
47
- self._session = session
48
- if self.mainFunc:
49
- self._main_task = asyncio.create_task(mainFunc())
50
-
51
- @self._component.on_disconnect
52
- @self._component.on_leave
53
- async def onLeave(*args, **kwargs):
54
- print("component left")
55
- if self._main_task:
56
- self._main_task.cancel()
57
- try:
58
- await self._main_task
59
- except asyncio.CancelledError:
60
- pass
61
- self._main_task = None
62
- self._session = None
40
+ self._is_configured = False
63
41
 
64
42
  @property
65
- def component(self) -> Component:
66
- """The Autobahn Component
43
+ def connection(self) -> CrossbarConnection:
44
+ """The CrossbarConnection instance
67
45
 
68
46
  Returns:
69
- Component
47
+ CrossbarConnection
70
48
  """
71
- return self._component
49
+ return self._connection
72
50
 
73
51
  @property
74
- def session(self) -> Optional[ISession]:
75
- """The Autobahn Session
52
+ def is_connected(self) -> bool:
53
+ """Check if the connection is established
76
54
 
77
55
  Returns:
78
- Optional[ISession]
56
+ bool: True if connected, False otherwise
79
57
  """
80
- return self._session
58
+ return self._connection.is_open
81
59
 
82
- async def publish(self, topic: str, *args, **kwargs) -> Optional[Publication]:
60
+ async def _configure_connection(self):
61
+ """Configure the CrossbarConnection with environment variables"""
62
+ if self._is_configured:
63
+ return
64
+
65
+ swarm_key = int(os.environ.get("SWARM_KEY", 0))
66
+ app_key = int(os.environ.get("APP_KEY", 0))
67
+ env_value = os.environ.get("ENV", "DEV").upper()
68
+
69
+ # Map environment string to Stage enum
70
+ stage_map = {
71
+ "DEV": Stage.DEVELOPMENT,
72
+ "PROD": Stage.PRODUCTION
73
+ }
74
+ stage = stage_map.get(env_value, Stage.DEVELOPMENT)
75
+
76
+ await self._connection.configure(
77
+ swarm_key=swarm_key,
78
+ app_key=app_key,
79
+ stage=stage,
80
+ serial_number=self._serial_number
81
+ )
82
+ self._is_configured = True
83
+
84
+ async def publish(self, topic: str, *args, **kwargs) -> Optional[Any]:
83
85
  """Publishes to the IronFlock Platform Message Router
84
86
 
85
87
  Args:
86
88
  topic (str): The URI of the topic to publish to, e.g. "com.myapp.mytopic1"
89
+ *args: Positional arguments to publish
90
+ **kwargs: Keyword arguments to publish
87
91
 
88
92
  Returns:
89
- Optional[Publication]: Object representing a publication
93
+ Optional[Any]: Object representing a publication
90
94
  (feedback from publishing an event when doing an acknowledged publish)
91
95
  """
96
+ if not self.is_connected:
97
+ print("cannot publish, not connected")
98
+ return None
92
99
 
93
- extra = {
100
+ # Add device metadata to kwargs
101
+ device_metadata = {
94
102
  "DEVICE_SERIAL_NUMBER": self._serial_number,
95
103
  "DEVICE_KEY": self._device_key,
96
104
  "DEVICE_NAME": self._device_name,
97
- "options": PublishOptions(acknowledge=True),
98
105
  }
99
-
100
- if self._session is not None:
101
- pub = await self._session.publish(topic, *args, **kwargs, **extra)
106
+
107
+ # Merge device metadata with user kwargs
108
+ combined_kwargs = {**device_metadata, **kwargs}
109
+
110
+ # Use acknowledged publish with proper PublishOptions
111
+ options = PublishOptions(acknowledge=True)
112
+
113
+ try:
114
+ pub = await self._connection.publish(
115
+ topic,
116
+ args=list(args),
117
+ kwargs=combined_kwargs,
118
+ options=options
119
+ )
102
120
  return pub
103
- else:
104
- print("cannot publish, not connected")
121
+ except Exception as e:
122
+ print(f"Publish failed: {e}")
123
+ return None
105
124
 
106
125
  async def set_device_location(self, long: float, lat: float):
107
126
  """Update the location of the device registered in the platform
108
- This will update the device's location in the master data of the platform.
109
- The maps in the device or group overviews will reflect the new device location in realtime.
110
- The location history will not be stored in the platform.
111
- If you need location history, then create a dedicated table for it.
127
+
128
+ This will update the device's location in the master data of the platform.
129
+ The maps in the device or group overviews will reflect the new device location in realtime.
130
+ The location history will not be stored in the platform.
131
+ If you need location history, then create a dedicated table for it.
132
+
133
+ Args:
134
+ long (float): Longitude coordinate
135
+ lat (float): Latitude coordinate
112
136
  """
137
+ if not self.is_connected:
138
+ print("cannot set location, not connected")
139
+ return None
113
140
 
114
141
  payload = {
115
142
  "long": long,
@@ -122,69 +149,118 @@ class IronFlock:
122
149
  "DEVICE_NAME": self._device_name
123
150
  }
124
151
 
125
- if hasattr(self, "_session") and hasattr(self._session, "call"):
126
- res = await self._session.call('ironflock.location_service.update', payload, **extra)
127
- return res
152
+ try:
153
+ res = await self._connection.call(
154
+ 'ironflock.location_service.update',
155
+ args=[payload],
156
+ kwargs=extra
157
+ )
158
+ return res
159
+ except Exception as e:
160
+ print(f"Set location failed: {e}")
161
+ return None
128
162
 
129
- async def register_function(self, topic: str, func):
130
- """Registers a function to be called when a message is received on the given topic.
131
-
163
+ async def register(self, topic: str, endpoint, options: Optional[dict] = None) -> Optional[Any]:
164
+ """Registers a function with the IronFlock Platform Message Router
165
+
132
166
  Args:
133
- topic (str): The URI of the topic to register the function for, e.g. "example.mytopic1".
134
- func (callable): The function to call when a message is received on the topic.
167
+ topic (str): The URI of the topic to register, e.g. "com.myapp.myprocedure1"
168
+ endpoint: The function to register
169
+ options (dict, optional): Registration options
170
+
171
+ Returns:
172
+ Optional[Any]: Object representing a registration
135
173
  """
136
- swarm_key = os.environ.get("SWARM_KEY")
137
- app_key = os.environ.get("APP_KEY")
138
- env_value = os.environ.get("ENV")
139
-
140
- full_topic = f"{swarm_key}.{self._device_key}.{app_key}.{env_value}.{topic}"
141
-
142
- if self._session is not None:
143
- await self._session.register(func, full_topic, options=RegisterOptions(force_reregister=True))
144
- else:
145
- print("cannot register function, not connected")
174
+ if not self.is_connected:
175
+ print("cannot register, not connected")
176
+ return None
177
+
178
+ # Convert options dict to RegisterOptions if provided
179
+ register_options = RegisterOptions(**options) if options else None
180
+
181
+ try:
182
+ reg = await self._connection.register(topic, endpoint, options=register_options)
183
+ return reg
184
+ except Exception as e:
185
+ print(f"Register failed: {e}")
186
+ return None
146
187
 
147
- async def call(self, device_key, topic, args, kwargs):
148
- """Calls a remote procedure on the IronFlock platform.
188
+ async def subscribe(self, topic: str, handler, options: Optional[dict] = None) -> Optional[Any]:
189
+ """Subscribes to a topic on the IronFlock Platform Message Router
149
190
 
150
191
  Args:
151
- device_key (str): The key of the device to call the procedure on.
152
- topic (str): The URI of the topic to call, e.g. "com.myprocedure".
153
- args (list): The arguments to pass to the procedure.
154
- kwargs (dict): The keyword arguments to pass to the procedure.
192
+ topic (str): The URI of the topic to subscribe to, e.g. "com.myapp.mytopic1"
193
+ handler: The function to call when a message is received
194
+ options (dict, optional): Subscription options
155
195
 
156
196
  Returns:
157
- The result of the remote procedure call.
197
+ Optional[Any]: Object representing a subscription
158
198
  """
159
-
160
- swarm_key = os.environ.get("SWARM_KEY")
161
- app_key = os.environ.get("APP_KEY")
162
- env_value = os.environ.get("ENV")
163
-
164
- full_topic = f"{swarm_key}.{device_key}.{app_key}.{env_value}.{topic}"
165
-
166
- if self._session is not None:
167
- return await self._session.call(full_topic, *args, **kwargs)
168
- else:
199
+ if not self.is_connected:
200
+ print("cannot subscribe, not connected")
201
+ return None
202
+
203
+ # Convert options dict to SubscribeOptions if provided
204
+ subscribe_options = SubscribeOptions(**options) if options else None
205
+
206
+ try:
207
+ sub = await self._connection.subscribe(topic, handler, options=subscribe_options)
208
+ return sub
209
+ except Exception as e:
210
+ print(f"Subscribe failed: {e}")
211
+ return None
212
+
213
+ async def call(self, device_key: str, topic: str, args: list = None, kwargs: dict = None, options: Optional[dict] = None):
214
+ """Calls a remote procedure registered by another IronFlock device
215
+
216
+ Args:
217
+ device_key (str): The device key of the target device
218
+ topic (str): The URI of the topic to call, e.g. "com.myapp.myprocedure1"
219
+ args (list, optional): Positional arguments for the call. Defaults to None.
220
+ kwargs (dict, optional): Keyword arguments for the call. Defaults to None.
221
+ options (dict, optional): Call options. Defaults to None.
222
+
223
+ Returns:
224
+ Any: The result of the remote procedure call
225
+ """
226
+ if not self.is_connected:
169
227
  print("cannot call, not connected")
170
228
  return None
171
229
 
230
+ args = args or []
231
+ kwargs = kwargs or {}
232
+
233
+ # Convert options dict to CallOptions if provided
234
+ call_options = CallOptions(**options) if options else None
235
+
236
+ call_topic = f"{device_key}.{topic}"
237
+
238
+ try:
239
+ result = await self._connection.call(call_topic, args=args, kwargs=kwargs, options=call_options)
240
+ return result
241
+ except Exception as e:
242
+ print(f"Call failed: {e}")
243
+ return None
244
+
172
245
  async def publish_to_table(
173
246
  self, tablename: str, *args, **kwargs
174
- ) -> Optional[Publication]:
175
- """Publishes Data to a Table in the IronFlock Platform. This is a conveniance function.
176
- You can achieve the same results by simply publishing a payload to the topic
177
-
178
- [SWARM_KEY].[APP_KEY].[your_table_name]
179
-
180
- The SWARM_KEY and APP_KEY are provided as environment variables to the device container.
181
- The also provided ENV variable holds either PROD or DEV to decide which topic to use, above.
182
- This function automatically detects the environment and publishes to the correct table.
247
+ ) -> Optional[Any]:
248
+ """Publishes Data to a Table in the IronFlock Platform. This is a convenience function.
249
+
250
+ You can achieve the same results by simply publishing a payload to the topic
251
+ [SWARM_KEY].[APP_KEY].[your_table_name]
252
+
253
+ The SWARM_KEY and APP_KEY are provided as environment variables to the device container.
254
+ The also provided ENV variable holds either PROD or DEV to decide which topic to use, above.
255
+ This function automatically detects the environment and publishes to the correct table.
256
+
183
257
  Args:
184
258
  tablename (str): The table name of the table to publish to, e.g. "sensordata"
259
+ *args: Positional arguments to publish
260
+ **kwargs: Keyword arguments to publish
185
261
 
186
262
  Returns:
187
- Optional[Publication]: Object representing a publication
263
+ Optional[Any]: Object representing a publication
188
264
  (feedback from publishing an event when doing an acknowledged publish)
189
265
  """
190
266
 
@@ -193,7 +269,6 @@ class IronFlock:
193
269
 
194
270
  swarm_key = os.environ.get("SWARM_KEY")
195
271
  app_key = os.environ.get("APP_KEY")
196
- env_value = os.environ.get("ENV")
197
272
 
198
273
  if swarm_key is None:
199
274
  raise Exception("Environment variable SWARM_KEY not set!")
@@ -206,9 +281,47 @@ class IronFlock:
206
281
  pub = await self.publish(topic, *args, **kwargs)
207
282
  return pub
208
283
 
209
- def run(self, wait=True):
210
- """Runs the Component in the asyncio event loop."""
211
- if wait:
212
- run([self._component])
213
- else:
214
- return self._component.start()
284
+ async def start(self):
285
+ """Start the connection and run the main function if provided"""
286
+ await self._configure_connection()
287
+ await self._connection.start()
288
+
289
+ if self.mainFunc:
290
+ self._main_task = asyncio.create_task(self.mainFunc())
291
+
292
+ async def stop(self):
293
+ """Stop the connection and cancel the main task if running"""
294
+ if self._main_task and not self._main_task.done():
295
+ self._main_task.cancel()
296
+ try:
297
+ await self._main_task
298
+ except asyncio.CancelledError:
299
+ pass
300
+ self._main_task = None
301
+
302
+ if self._connection:
303
+ await self._connection.stop()
304
+
305
+ async def run(self):
306
+ """Start the connection and keep it running"""
307
+ await self.start()
308
+
309
+ try:
310
+ # Keep running until manually stopped
311
+ if self._main_task:
312
+ await self._main_task
313
+ else:
314
+ # If no main function, just wait indefinitely
315
+ while self.is_connected:
316
+ await asyncio.sleep(1)
317
+ except KeyboardInterrupt:
318
+ print("Shutting down...")
319
+ except Exception as e:
320
+ print(f"Exception in run(): {e}")
321
+ raise
322
+ finally:
323
+ await self.stop()
324
+
325
+ def run_sync(self):
326
+ """Synchronous wrapper to run the IronFlock instance (for backward compatibility)"""
327
+ asyncio.run(self.run())
@@ -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.1
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,7 @@
1
+ ironflock/CrossbarConnection.py,sha256=kWoWQ8TqZORq5XhuL3-c22yIFBzGMpRbCZpztKPMNyQ,12802
2
+ ironflock/__init__.py,sha256=5DreQfXO9MKAaEDxHm_L2fzCdbt9gRg3K8bV--ESoMo,203
3
+ ironflock/ironflock.py,sha256=3Qd4VvrxTn5nekRZhwIzj_XFbr3Mayct0XzCkYSwK-8,11674
4
+ ironflock-1.2.1.dist-info/METADATA,sha256=4ZU45mFIsw8wQ5mMiMRySmtjFwSG2jwmuXfhzr--_BM,3425
5
+ ironflock-1.2.1.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
6
+ ironflock-1.2.1.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
7
+ ironflock-1.2.1.dist-info/RECORD,,
@@ -1,5 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
-
examples/app_session.py DELETED
@@ -1,32 +0,0 @@
1
- ###
2
- # Minimal example of creating an Autobahn asyncio ApplicationSession
3
- # for connecting to the IronFlock Platform.
4
- # Compared to the IronFlock() class, the ApplicationSession approach allows
5
- # more control reacting to the lifecycle of the Session:
6
- # you can register callback functions like on_join or on_leave.
7
- # For more details checkout:
8
- # https://autobahn.readthedocs.io/en/latest/wamp/programming.html#application-components
9
- ###
10
-
11
- from asyncio import sleep
12
- from autobahn.wamp.interfaces import ISession
13
- from ironflock import create_application_session
14
-
15
- # returns an Autobahn asyncio ApplicationSession and ApplicationRunner, for more information checkout:
16
- # https://autobahn.readthedocs.io/en/latest/reference/autobahn.asyncio.html
17
- AppSession, runner = create_application_session()
18
-
19
-
20
- class Application(AppSession):
21
- async def onJoin(session: ISession, details):
22
- print("joined router")
23
- print(session, details)
24
-
25
- # publish an event every second
26
- while True:
27
- session.publish("test.publish.com", 1, "two", 3, foo="bar")
28
- await sleep(1)
29
-
30
-
31
- if __name__ == "__main__":
32
- runner.run(Application)
examples/component.py DELETED
@@ -1,40 +0,0 @@
1
- ###
2
- # Minimal example of creating an Autobahn asyncio Component
3
- # for connecting to the IronFlock Platform.
4
- # Compared to the IronFlock() class, the Component approach allows
5
- # more control, e.g. reacting to the lifecycle of the component:
6
- # you can register callback functions like on_join or on_leave.
7
- # For more details checkout:
8
- # https://autobahn.readthedocs.io/en/latest/wamp/programming.html#application-components
9
- ###
10
-
11
- from asyncio import sleep
12
- from autobahn.asyncio.component import run
13
- from autobahn.wamp.interfaces import ISession
14
- from ironflock import create_application_component
15
-
16
- # returns an Autobahn asyncio Component, for more information checkout:
17
- # https://autobahn.readthedocs.io/en/latest/reference/autobahn.asyncio.html
18
- comp = create_application_component()
19
-
20
-
21
- @comp.on_join
22
- async def onJoin(session: ISession, details):
23
- print("joined router")
24
- print(session, details)
25
-
26
- def handler(*args, **kwargs):
27
- print("got event")
28
- print(args, kwargs)
29
-
30
- # subscribe to a topic
31
- session.subscribe(handler, "test.publish.com")
32
-
33
- # publish an event every second
34
- while True:
35
- session.publish("test.publish.com", 1, "two", 3, foo="bar")
36
- await sleep(1)
37
-
38
-
39
- if __name__ == "__main__":
40
- run([comp])
@@ -1,25 +0,0 @@
1
- ###
2
- # Minimal example of publishing events to the IronFlock Platform.
3
- ###
4
-
5
- import asyncio
6
- from ironflock import IronFlock
7
-
8
- # create a ironflock instance, which auto connects to the IronFlock Platform
9
- # the ironflock instance handles authentication and reconnects when connection is lost
10
- rw = IronFlock()
11
-
12
-
13
- async def main():
14
- while True:
15
- # publish an event (if connection is not established the publish is skipped)
16
- publication = await rw.publish("test.publish.com", 1, "two", 3, foo="bar")
17
- print(publication)
18
- await asyncio.sleep(3)
19
-
20
-
21
- if __name__ == "__main__":
22
- # run the main coroutine
23
- asyncio.get_event_loop().create_task(main())
24
- # run the ironflock component
25
- rw.run()
examples/table_publish.py DELETED
@@ -1,29 +0,0 @@
1
- ###
2
- # Minimal example of publishing events to the IronFlock Platform.
3
- ###
4
-
5
- import asyncio
6
- from datetime import datetime
7
- from ironflock import IronFlock
8
-
9
- # create a ironflock instance, which auto connects to the IronFlock Platform
10
- # the ironflock instance handles authentication and reconnects when connection is lost
11
- rw = IronFlock()
12
-
13
-
14
- async def main():
15
- while True:
16
- # publish an event (if connection is not established the publish is skipped)
17
- publication = await rw.publish_to_table(
18
- "sensordata",
19
- dict(temperature=25, tsp=datetime.now().astimezone().isoformat()),
20
- )
21
- print(publication)
22
- await asyncio.sleep(3)
23
-
24
-
25
- if __name__ == "__main__":
26
- # run the main coroutine
27
- asyncio.get_event_loop().create_task(main())
28
- # run the ironflock component
29
- rw.run()
@@ -1,126 +0,0 @@
1
- import os
2
- from typing import Tuple
3
- from autobahn.asyncio.component import Component
4
- from autobahn.asyncio.wamp import ApplicationSession, ApplicationRunner
5
- from autobahn.wamp import auth
6
-
7
- try:
8
- SWARM_KEY = os.environ["SWARM_KEY"]
9
- except:
10
- raise Exception("Environment variable SWARM_KEY not set!")
11
-
12
- try:
13
- APP_KEY = os.environ["APP_KEY"]
14
- except:
15
- raise Exception("Environment variable APP_KEY not set!")
16
-
17
- try:
18
- ENV = os.environ["ENV"].lower()
19
- except:
20
- raise Exception("Environment variable ENV not set!")
21
-
22
- # CB_REALM = "userapps"
23
- CB_REALM = f"realm-{SWARM_KEY}-{APP_KEY}-{ENV}"
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
-
39
- def getWebSocketURI():
40
- reswarm_url = os.environ.get("RESWARM_URL")
41
- if not reswarm_url:
42
- return STUDIO_WS_URI
43
- return socketURIMap.get(reswarm_url)
44
-
45
-
46
- def getSerialNumber(serial_number: str = None) -> str:
47
- if serial_number is None:
48
- s_num = os.environ.get("DEVICE_SERIAL_NUMBER")
49
- if s_num is None:
50
- raise Exception("ENV Variable 'DEVICE_SERIAL_NUMBER' is not set!")
51
- else:
52
- s_num = serial_number
53
- return s_num
54
-
55
-
56
- class AppSession(ApplicationSession):
57
- serial_number: str = None
58
-
59
- def onConnect(self):
60
- print('onConnect called')
61
- if self.serial_number is None:
62
- raise Exception("serial_number missing on AppSession")
63
-
64
- self.join(CB_REALM, ["wampcra"], self.serial_number)
65
-
66
- def onChallenge(self, challenge):
67
- print('challenge requested for {}'.format(challenge.method))
68
- if challenge.method == "wampcra":
69
- if self.serial_number is None:
70
- raise Exception("serial_number missing on AppSession")
71
-
72
- signature = auth.compute_wcs(
73
- self.serial_number, challenge.extra["challenge"]
74
- )
75
- return signature
76
-
77
- raise Exception("Invalid authmethod {}".format(challenge.method))
78
-
79
-
80
- def create_application_session(
81
- serial_number: str = None,
82
- ) -> Tuple[ApplicationSession, ApplicationRunner]:
83
- """Creates an Autobahn ApplicationSession and ApplicationRunner, which connects to the IronFlock Platform
84
-
85
- Args:
86
- serial_number (str, optional): serial_number of device.
87
- Defaults to None, in which case the environment variable DEVICE_SERIAL_NUMBER is used.
88
-
89
- Returns:
90
- Tuple[ApplicationSession, ApplicationRunner]
91
- """
92
- AppSession.serial_number = getSerialNumber(serial_number)
93
-
94
- runner = ApplicationRunner(
95
- url=getWebSocketURI(),
96
- realm=CB_REALM,
97
- )
98
-
99
- print('Application runner created')
100
-
101
- return AppSession, runner
102
-
103
-
104
- def create_application_component(serial_number: str = None) -> Component:
105
- """Creates an Autobahn Component, which connects to the IronFlock Platform
106
-
107
- Args:
108
- serial_number (str, optional): serial_number of device.
109
- Defaults to None, in which case the environment variable DEVICE_SERIAL_NUMBER is used.
110
-
111
- Returns:
112
- Component
113
- """
114
- appSession, _ = create_application_session(serial_number)
115
-
116
- comp = Component(
117
- transports=[{
118
- "url": getWebSocketURI(),
119
- "serializers": ['msgpack'],
120
- }],
121
- realm=CB_REALM,
122
- session_factory=appSession,
123
- )
124
- print('WAMP Component created')
125
-
126
- return comp
@@ -1,13 +0,0 @@
1
- examples/app_session.py,sha256=ugpaZkqbhcn_DzIMrCLSoPjcPtacyKEDVWj0V2fHZJc,1151
2
- examples/component.py,sha256=1wvYL1QSRh6t9csh_WPZiMB6GtfmU7PGwSYZtNfQyTg,1255
3
- examples/simple_publish.py,sha256=3wImf1nikJEHynIvMSx4ofiSFhhbdsBJw3awLA-wYcE,720
4
- examples/table_publish.py,sha256=IXsWRoLI9u1CqH_9mB-k4TLYEVOjGtcXzWQ3mzcxg1Q,831
5
- ironflock/AutobahnConnection.py,sha256=Xh1Y7s269XdLTfDYe6moTiJda7cPzCkknICSqxN3fn4,3744
6
- ironflock/__init__.py,sha256=Ia3Bb7BjyuGl1YBp3fHz0tHS-CsDtH-JSrjbtl-u7S4,264
7
- ironflock/ironflock.py,sha256=3OftdBL1Fb4fMKNz3ttnycMAN3GmruIkFkjmKaOzahQ,7867
8
- ironflock-1.1.0.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
9
- test/main.py,sha256=Gp4N9nuUWrCDti4-9W5AoGFDX2cWujKg3Bbq1kJcEP8,328
10
- ironflock-1.1.0.dist-info/METADATA,sha256=hD_x-g1q0ZV4BXPLIsYYTd5byJ2j1jGCKKxGXLPQBXc,3403
11
- ironflock-1.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
- ironflock-1.1.0.dist-info/top_level.txt,sha256=ik-lPYQ3yaZZm6kJURMiJmM2MJh9ExxRSOy-HruvpwY,24
13
- ironflock-1.1.0.dist-info/RECORD,,
@@ -1,3 +0,0 @@
1
- examples
2
- ironflock
3
- test
test/main.py DELETED
@@ -1,10 +0,0 @@
1
- from asyncio.events import get_event_loop
2
- from ironflock import IronFlock
3
-
4
- async def main():
5
- rw = IronFlock(serial_number="7652ee0b-c2cb-466a-b8ee-fec4167bf7ce")
6
- result = await rw.publish('re.meetup.data', {"temperature": 20})
7
- print(result)
8
-
9
- if __name__ == "__main__":
10
- get_event_loop().run_until_complete(main())