ironflock 1.1.0__py3-none-any.whl → 1.2.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.
@@ -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)
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,25 @@
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
10
6
 
11
7
 
12
8
  class IronFlock:
13
- """Conveniance class for easy-to-use message publishing in the IronFlock platform.
9
+ """Convenience class for easy-to-use message publishing in the IronFlock platform.
14
10
 
15
11
  Example:
16
12
 
17
13
  async def main():
18
14
  while True:
19
- publication = await ironFlock.publish("test.publish.pw", 1, "two", 3, foo="bar")
15
+ publication = await ironflock.publish("test.publish.pw", 1, "two", 3, foo="bar")
20
16
  print(publication)
21
17
  await asyncio.sleep(3)
22
18
 
23
19
 
24
20
  if __name__ == "__main__":
25
21
  ironflock = IronFlock(mainFunc=main)
26
- ironFlock.run()
22
+ await ironflock.run()
27
23
  """
28
24
 
29
25
  def __init__(self, serial_number: str = None, mainFunc=None) -> None:
@@ -32,84 +28,114 @@ class IronFlock:
32
28
  Args:
33
29
  serial_number (str, optional): serial_number of device.
34
30
  Defaults to None, in which case the environment variable DEVICE_SERIAL_NUMBER is used.
31
+ mainFunc (callable, optional): Main function to run after connection is established.
35
32
  """
36
33
  self._serial_number = getSerialNumber(serial_number)
37
34
  self._device_name = os.environ.get("DEVICE_NAME")
38
35
  self._device_key = os.environ.get("DEVICE_KEY")
39
- self._component = create_application_component(serial_number)
40
- self._session: ISession = None
36
+ self._connection = CrossbarConnection()
41
37
  self.mainFunc = mainFunc
42
38
  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
39
+ self._is_configured = False
63
40
 
64
41
  @property
65
- def component(self) -> Component:
66
- """The Autobahn Component
42
+ def connection(self) -> CrossbarConnection:
43
+ """The CrossbarConnection instance
67
44
 
68
45
  Returns:
69
- Component
46
+ CrossbarConnection
70
47
  """
71
- return self._component
48
+ return self._connection
72
49
 
73
50
  @property
74
- def session(self) -> Optional[ISession]:
75
- """The Autobahn Session
51
+ def is_connected(self) -> bool:
52
+ """Check if the connection is established
76
53
 
77
54
  Returns:
78
- Optional[ISession]
55
+ bool: True if connected, False otherwise
79
56
  """
80
- return self._session
57
+ return self._connection.is_open
81
58
 
82
- async def publish(self, topic: str, *args, **kwargs) -> Optional[Publication]:
59
+ async def _configure_connection(self):
60
+ """Configure the CrossbarConnection with environment variables"""
61
+ if self._is_configured:
62
+ return
63
+
64
+ swarm_key = int(os.environ.get("SWARM_KEY", 0))
65
+ app_key = int(os.environ.get("APP_KEY", 0))
66
+ env_value = os.environ.get("ENV", "DEV").upper()
67
+
68
+ # Map environment string to Stage enum
69
+ stage_map = {
70
+ "DEV": Stage.DEVELOPMENT,
71
+ "PROD": Stage.PRODUCTION
72
+ }
73
+ stage = stage_map.get(env_value, Stage.DEVELOPMENT)
74
+
75
+ await self._connection.configure(
76
+ swarm_key=swarm_key,
77
+ app_key=app_key,
78
+ stage=stage,
79
+ serial_number=self._serial_number
80
+ )
81
+ self._is_configured = True
82
+
83
+ async def publish(self, topic: str, *args, **kwargs) -> Optional[Any]:
83
84
  """Publishes to the IronFlock Platform Message Router
84
85
 
85
86
  Args:
86
87
  topic (str): The URI of the topic to publish to, e.g. "com.myapp.mytopic1"
88
+ *args: Positional arguments to publish
89
+ **kwargs: Keyword arguments to publish
87
90
 
88
91
  Returns:
89
- Optional[Publication]: Object representing a publication
92
+ Optional[Any]: Object representing a publication
90
93
  (feedback from publishing an event when doing an acknowledged publish)
91
94
  """
95
+ if not self.is_connected:
96
+ print("cannot publish, not connected")
97
+ return None
92
98
 
93
- extra = {
99
+ # Add device metadata to kwargs
100
+ device_metadata = {
94
101
  "DEVICE_SERIAL_NUMBER": self._serial_number,
95
102
  "DEVICE_KEY": self._device_key,
96
103
  "DEVICE_NAME": self._device_name,
97
- "options": PublishOptions(acknowledge=True),
98
104
  }
99
-
100
- if self._session is not None:
101
- pub = await self._session.publish(topic, *args, **kwargs, **extra)
105
+
106
+ # Merge device metadata with user kwargs
107
+ combined_kwargs = {**device_metadata, **kwargs}
108
+
109
+ # Use acknowledged publish
110
+ options = {"acknowledge": True}
111
+
112
+ try:
113
+ pub = await self._connection.publish(
114
+ topic,
115
+ args=list(args),
116
+ kwargs=combined_kwargs,
117
+ options=options
118
+ )
102
119
  return pub
103
- else:
104
- print("cannot publish, not connected")
120
+ except Exception as e:
121
+ print(f"Publish failed: {e}")
122
+ return None
105
123
 
106
124
  async def set_device_location(self, long: float, lat: float):
107
125
  """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.
126
+
127
+ This will update the device's location in the master data of the platform.
128
+ The maps in the device or group overviews will reflect the new device location in realtime.
129
+ The location history will not be stored in the platform.
130
+ If you need location history, then create a dedicated table for it.
131
+
132
+ Args:
133
+ long (float): Longitude coordinate
134
+ lat (float): Latitude coordinate
112
135
  """
136
+ if not self.is_connected:
137
+ print("cannot set location, not connected")
138
+ return None
113
139
 
114
140
  payload = {
115
141
  "long": long,
@@ -122,9 +148,16 @@ class IronFlock:
122
148
  "DEVICE_NAME": self._device_name
123
149
  }
124
150
 
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
151
+ try:
152
+ res = await self._connection.call(
153
+ 'ironflock.location_service.update',
154
+ args=[payload],
155
+ kwargs=extra
156
+ )
157
+ return res
158
+ except Exception as e:
159
+ print(f"Set location failed: {e}")
160
+ return None
128
161
 
129
162
  async def register_function(self, topic: str, func):
130
163
  """Registers a function to be called when a message is received on the given topic.
@@ -133,18 +166,26 @@ class IronFlock:
133
166
  topic (str): The URI of the topic to register the function for, e.g. "example.mytopic1".
134
167
  func (callable): The function to call when a message is received on the topic.
135
168
  """
169
+ if not self.is_connected:
170
+ print("cannot register function, not connected")
171
+ return None
172
+
136
173
  swarm_key = os.environ.get("SWARM_KEY")
137
174
  app_key = os.environ.get("APP_KEY")
138
175
  env_value = os.environ.get("ENV")
139
176
 
140
177
  full_topic = f"{swarm_key}.{self._device_key}.{app_key}.{env_value}.{topic}"
141
178
 
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")
179
+ try:
180
+ # Note: CrossbarConnection doesn't support force_reregister option directly
181
+ # but it handles resubscription automatically on reconnect
182
+ registration = await self._connection.register(full_topic, func)
183
+ return registration
184
+ except Exception as e:
185
+ print(f"Register function failed: {e}")
186
+ return None
146
187
 
147
- async def call(self, device_key, topic, args, kwargs):
188
+ async def call(self, device_key: str, topic: str, args: list = None, kwargs: dict = None):
148
189
  """Calls a remote procedure on the IronFlock platform.
149
190
 
150
191
  Args:
@@ -156,35 +197,46 @@ class IronFlock:
156
197
  Returns:
157
198
  The result of the remote procedure call.
158
199
  """
159
-
200
+ if not self.is_connected:
201
+ print("cannot call, not connected")
202
+ return None
203
+
160
204
  swarm_key = os.environ.get("SWARM_KEY")
161
205
  app_key = os.environ.get("APP_KEY")
162
206
  env_value = os.environ.get("ENV")
163
207
 
164
208
  full_topic = f"{swarm_key}.{device_key}.{app_key}.{env_value}.{topic}"
165
209
 
166
- if self._session is not None:
167
- return await self._session.call(full_topic, *args, **kwargs)
168
- else:
169
- print("cannot call, not connected")
210
+ try:
211
+ result = await self._connection.call(
212
+ full_topic,
213
+ args=args or [],
214
+ kwargs=kwargs or {}
215
+ )
216
+ return result
217
+ except Exception as e:
218
+ print(f"Call failed: {e}")
170
219
  return None
171
220
 
172
221
  async def publish_to_table(
173
222
  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.
223
+ ) -> Optional[Any]:
224
+ """Publishes Data to a Table in the IronFlock Platform. This is a convenience function.
225
+
226
+ You can achieve the same results by simply publishing a payload to the topic
227
+ [SWARM_KEY].[APP_KEY].[your_table_name]
228
+
229
+ The SWARM_KEY and APP_KEY are provided as environment variables to the device container.
230
+ The also provided ENV variable holds either PROD or DEV to decide which topic to use, above.
231
+ This function automatically detects the environment and publishes to the correct table.
232
+
183
233
  Args:
184
234
  tablename (str): The table name of the table to publish to, e.g. "sensordata"
235
+ *args: Positional arguments to publish
236
+ **kwargs: Keyword arguments to publish
185
237
 
186
238
  Returns:
187
- Optional[Publication]: Object representing a publication
239
+ Optional[Any]: Object representing a publication
188
240
  (feedback from publishing an event when doing an acknowledged publish)
189
241
  """
190
242
 
@@ -193,7 +245,6 @@ class IronFlock:
193
245
 
194
246
  swarm_key = os.environ.get("SWARM_KEY")
195
247
  app_key = os.environ.get("APP_KEY")
196
- env_value = os.environ.get("ENV")
197
248
 
198
249
  if swarm_key is None:
199
250
  raise Exception("Environment variable SWARM_KEY not set!")
@@ -206,9 +257,43 @@ class IronFlock:
206
257
  pub = await self.publish(topic, *args, **kwargs)
207
258
  return pub
208
259
 
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()
260
+ async def start(self):
261
+ """Start the connection and run the main function if provided"""
262
+ await self._configure_connection()
263
+ await self._connection.start()
264
+
265
+ if self.mainFunc:
266
+ self._main_task = asyncio.create_task(self.mainFunc())
267
+
268
+ async def stop(self):
269
+ """Stop the connection and cancel the main task if running"""
270
+ if self._main_task and not self._main_task.done():
271
+ self._main_task.cancel()
272
+ try:
273
+ await self._main_task
274
+ except asyncio.CancelledError:
275
+ pass
276
+ self._main_task = None
277
+
278
+ await self._connection.stop()
279
+
280
+ async def run(self):
281
+ """Start the connection and keep it running"""
282
+ await self.start()
283
+
284
+ try:
285
+ # Keep running until manually stopped
286
+ if self._main_task:
287
+ await self._main_task
288
+ else:
289
+ # If no main function, just wait indefinitely
290
+ while self.is_connected:
291
+ await asyncio.sleep(1)
292
+ except KeyboardInterrupt:
293
+ print("Shutting down...")
294
+ finally:
295
+ await self.stop()
296
+
297
+ def run_sync(self):
298
+ """Synchronous wrapper to run the IronFlock instance (for backward compatibility)"""
299
+ 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.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,7 @@
1
+ ironflock/CrossbarConnection.py,sha256=Q7SNHTNbi7AJC4SbFf7_TLqS10dIGGzJnwY0iVIU2Ek,12727
2
+ ironflock/__init__.py,sha256=5DreQfXO9MKAaEDxHm_L2fzCdbt9gRg3K8bV--ESoMo,203
3
+ ironflock/ironflock.py,sha256=Efs6cAl7dP6CzN1XRFQRg6xw7ULIX_4EvXpXDXUJUK0,10531
4
+ ironflock-1.2.0.dist-info/METADATA,sha256=6N0-bI8AzKlbBTO4kMj_fQSdOHBVCXLczzaF_r6uoJI,3425
5
+ ironflock-1.2.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
6
+ ironflock-1.2.0.dist-info/licenses/LICENSE,sha256=GpUKjPB381nmkbBIdX74vxXhsNZaNpngTOciss39Pjk,1073
7
+ ironflock-1.2.0.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())