phystack-hub-client 0.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,225 @@
1
+ """Base instance class with common functionality for all twin types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Callable, Optional
6
+
7
+ from phystack.hub_client.types.twin_types import (
8
+ TwinMessageResult,
9
+ TwinProperties,
10
+ TwinResponse,
11
+ )
12
+ from phystack.hub_client.twin_messaging import (
13
+ TwinMessaging,
14
+ TargetedEmitter,
15
+ )
16
+
17
+ if TYPE_CHECKING:
18
+ from phystack.hub_client.client import PhyHubClient
19
+ from phystack.hub_client.services.webrtc.data_channel import (
20
+ PhygridDataChannel,
21
+ DataChannelOptions,
22
+ )
23
+ from phystack.hub_client.services.webrtc.media_stream import (
24
+ PhygridMediaStream,
25
+ MediaStreamOptions,
26
+ )
27
+
28
+
29
+ class BaseInstance:
30
+ """Base class for all twin instances with common messaging and WebRTC."""
31
+
32
+ def __init__(
33
+ self,
34
+ twin_id: str,
35
+ device_id: str,
36
+ client: "PhyHubClient",
37
+ messaging: TwinMessaging,
38
+ twin_data: Optional[dict[str, Any]] = None,
39
+ ) -> None:
40
+ """Initialize base instance.
41
+
42
+ Args:
43
+ twin_id: ID of this twin.
44
+ device_id: ID of the parent device.
45
+ client: PhyHubClient for API calls.
46
+ messaging: TwinMessaging instance for emit/on/to.
47
+ twin_data: Optional cached twin data.
48
+ """
49
+ self.id = twin_id
50
+ self.device_id = device_id
51
+ self._client = client
52
+ self._messaging = messaging
53
+ self._twin_data = twin_data or {}
54
+
55
+ @property
56
+ def properties(self) -> TwinProperties:
57
+ """Access twin properties (desired/reported)."""
58
+ props = self._twin_data.get("properties", {})
59
+ return TwinProperties.from_dict(props)
60
+
61
+ @property
62
+ def tenant_id(self) -> str:
63
+ """Get tenant ID."""
64
+ return self._twin_data.get("tenantId", "")
65
+
66
+ @property
67
+ def type(self) -> str:
68
+ """Get twin type."""
69
+ return self._twin_data.get("type", "")
70
+
71
+ @property
72
+ def status(self) -> str:
73
+ """Get twin status."""
74
+ return self._twin_data.get("status", "")
75
+
76
+ @property
77
+ def descriptors(self) -> dict[str, str]:
78
+ """Get twin descriptors."""
79
+ return self._twin_data.get("descriptors", {})
80
+
81
+ def _update_twin_data(self, data: dict[str, Any]) -> None:
82
+ """Update cached twin data."""
83
+ self._twin_data = {**self._twin_data, **data}
84
+
85
+ # =========================================================================
86
+ # Messaging API (delegated to TwinMessaging)
87
+ # =========================================================================
88
+
89
+ def emit(self, event_type: str, payload: Any = None) -> None:
90
+ """Fire-and-forget broadcast to subscribers.
91
+
92
+ Args:
93
+ event_type: Type of event to emit.
94
+ payload: Data to send.
95
+ """
96
+ self._messaging.emit(event_type, payload)
97
+
98
+ def on(
99
+ self,
100
+ event_type: str,
101
+ callback: Callable[[Any, Optional[Callable[[TwinMessageResult], None]]], None],
102
+ ) -> None:
103
+ """Listen for events, with optional respond callback for request-response.
104
+
105
+ Args:
106
+ event_type: Type of event to listen for.
107
+ callback: Handler function (data, respond?) => None
108
+ """
109
+ self._messaging.on(event_type, callback)
110
+
111
+ def to(self, target_twin_id: str) -> TargetedEmitter:
112
+ """Target a specific twin for messaging.
113
+
114
+ Args:
115
+ target_twin_id: ID of twin to target.
116
+
117
+ Returns:
118
+ TargetedEmitter for the specified twin.
119
+ """
120
+ return self._messaging.to(target_twin_id)
121
+
122
+ # =========================================================================
123
+ # WebRTC API (delegated to client)
124
+ # =========================================================================
125
+
126
+ async def get_data_channel(
127
+ self,
128
+ target_twin_id: str,
129
+ channel_name: str = "default",
130
+ options: Optional["DataChannelOptions"] = None,
131
+ ) -> "PhygridDataChannel":
132
+ """Create a WebRTC DataChannel to another twin.
133
+
134
+ Args:
135
+ target_twin_id: ID of the target twin.
136
+ channel_name: Name for the data channel.
137
+ options: Optional DataChannel configuration.
138
+
139
+ Returns:
140
+ PhygridDataChannel for sending/receiving data.
141
+ """
142
+ return await self._client.create_data_channel(
143
+ target_twin_id=target_twin_id,
144
+ is_initiator=True,
145
+ options=options,
146
+ channel_name=channel_name,
147
+ )
148
+
149
+ async def on_data_channel(
150
+ self,
151
+ target_twin_id: str,
152
+ callback: Callable[["PhygridDataChannel"], None],
153
+ channel_name: str = "default",
154
+ ) -> None:
155
+ """Listen for incoming DataChannel from a twin.
156
+
157
+ Args:
158
+ target_twin_id: ID of the twin to listen from.
159
+ callback: Handler for incoming channel.
160
+ channel_name: Name of the channel to listen for.
161
+ """
162
+ channel = await self._client.create_data_channel(
163
+ target_twin_id=target_twin_id,
164
+ is_initiator=False,
165
+ channel_name=channel_name,
166
+ blocking=False,
167
+ )
168
+ # Call callback when channel is opened
169
+ if channel.is_open():
170
+ callback(channel)
171
+ else:
172
+ original_on_open = None
173
+
174
+ def on_open() -> None:
175
+ callback(channel)
176
+
177
+ channel._handler._on_connected = on_open
178
+
179
+ async def get_media_stream(
180
+ self,
181
+ target_twin_id: str,
182
+ stream_name: str = "default",
183
+ options: Optional["MediaStreamOptions"] = None,
184
+ ) -> "PhygridMediaStream":
185
+ """Create a WebRTC MediaStream to another twin.
186
+
187
+ Args:
188
+ target_twin_id: ID of the target twin.
189
+ stream_name: Name for the media stream.
190
+ options: Optional MediaStream configuration.
191
+
192
+ Returns:
193
+ PhygridMediaStream for video/audio streaming.
194
+ """
195
+ return await self._client.create_media_stream(
196
+ target_twin_id=target_twin_id,
197
+ is_initiator=True,
198
+ options=options,
199
+ stream_name=stream_name,
200
+ )
201
+
202
+ async def on_media_stream(
203
+ self,
204
+ target_twin_id: str,
205
+ callback: Callable[["PhygridMediaStream"], None],
206
+ stream_name: str = "default",
207
+ ) -> None:
208
+ """Listen for incoming MediaStream from a twin.
209
+
210
+ Args:
211
+ target_twin_id: ID of the twin to listen from.
212
+ callback: Handler for incoming stream.
213
+ stream_name: Name of the stream to listen for.
214
+ """
215
+ stream = await self._client.create_media_stream(
216
+ target_twin_id=target_twin_id,
217
+ is_initiator=False,
218
+ stream_name=stream_name,
219
+ blocking=False,
220
+ )
221
+ # Call callback when stream is opened
222
+ if stream.is_open():
223
+ callback(stream)
224
+ else:
225
+ stream._handler._on_connected = lambda: callback(stream)
@@ -0,0 +1,212 @@
1
+ """EdgeInstance class for edge twin management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Set
7
+
8
+ from phystack.hub_client.instances.base_instance import BaseInstance
9
+ from phystack.hub_client.types.twin_types import (
10
+ TwinResponse,
11
+ TwinTypeEnum,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from phystack.hub_client.client import PhyHubClient
16
+ from phystack.hub_client.twin_messaging import TwinMessaging
17
+
18
+
19
+ class EdgeInstance(BaseInstance):
20
+ """Edge twin instance with peripheral and settings management."""
21
+
22
+ def __init__(
23
+ self,
24
+ twin_id: str,
25
+ device_id: str,
26
+ client: "PhyHubClient",
27
+ messaging: "TwinMessaging",
28
+ twin_data: Optional[dict[str, Any]] = None,
29
+ ) -> None:
30
+ """Initialize edge instance.
31
+
32
+ Args:
33
+ twin_id: ID of this edge twin.
34
+ device_id: ID of the parent device.
35
+ client: PhyHubClient for API calls.
36
+ messaging: TwinMessaging instance for emit/on/to.
37
+ twin_data: Optional cached twin data.
38
+ """
39
+ super().__init__(twin_id, device_id, client, messaging, twin_data)
40
+ self._update_reported_listeners: Set[Callable[[dict[str, Any]], None]] = set()
41
+ self._update_desired_listeners: Set[Callable[[dict[str, Any]], None]] = set()
42
+ self._previous_reported: Optional[str] = None
43
+ self._previous_desired: Optional[str] = None
44
+
45
+ # =========================================================================
46
+ # Properties API
47
+ # =========================================================================
48
+
49
+ async def update_reported(self, properties: dict[str, Any]) -> dict[str, Any]:
50
+ """Update reported properties for this edge twin.
51
+
52
+ Args:
53
+ properties: Properties to update.
54
+
55
+ Returns:
56
+ Updated twin data.
57
+ """
58
+ result = await self._client.update_reported_properties(self.id, properties)
59
+ if result:
60
+ self._update_twin_data(result)
61
+ return result or {}
62
+
63
+ def on_update_reported(
64
+ self, callback: Callable[[dict[str, Any]], None]
65
+ ) -> None:
66
+ """Subscribe to reported property changes.
67
+
68
+ Args:
69
+ callback: Handler called with new reported properties when they change.
70
+ """
71
+ self._update_reported_listeners.add(callback)
72
+
73
+ # Set up twin update listener if not already set
74
+ self._setup_property_listeners()
75
+
76
+ def on_update_desired(
77
+ self, callback: Callable[[dict[str, Any]], None]
78
+ ) -> None:
79
+ """Subscribe to desired property changes.
80
+
81
+ Args:
82
+ callback: Handler called with new desired properties when they change.
83
+ """
84
+ self._update_desired_listeners.add(callback)
85
+
86
+ # Set up twin update listener if not already set
87
+ self._setup_property_listeners()
88
+
89
+ def _setup_property_listeners(self) -> None:
90
+ """Set up twin update listener for property change detection."""
91
+
92
+ def on_twin_update(twin_data: dict[str, Any]) -> None:
93
+ if twin_data.get("id") != self.id:
94
+ return
95
+
96
+ props = twin_data.get("properties", {})
97
+
98
+ # Check reported changes
99
+ reported = props.get("reported", {})
100
+ reported_str = json.dumps(reported, sort_keys=True)
101
+ if self._previous_reported is None:
102
+ self._previous_reported = reported_str
103
+ for listener in self._update_reported_listeners:
104
+ try:
105
+ listener(reported)
106
+ except Exception as e:
107
+ print(f"[EdgeInstance] Error in reported listener: {e}")
108
+ elif reported_str != self._previous_reported:
109
+ self._previous_reported = reported_str
110
+ for listener in self._update_reported_listeners:
111
+ try:
112
+ listener(reported)
113
+ except Exception as e:
114
+ print(f"[EdgeInstance] Error in reported listener: {e}")
115
+
116
+ # Check desired changes
117
+ desired = props.get("desired", {})
118
+ desired_str = json.dumps(desired, sort_keys=True)
119
+ if self._previous_desired is None:
120
+ self._previous_desired = desired_str
121
+ for listener in self._update_desired_listeners:
122
+ try:
123
+ listener(desired)
124
+ except Exception as e:
125
+ print(f"[EdgeInstance] Error in desired listener: {e}")
126
+ elif desired_str != self._previous_desired:
127
+ self._previous_desired = desired_str
128
+ for listener in self._update_desired_listeners:
129
+ try:
130
+ listener(desired)
131
+ except Exception as e:
132
+ print(f"[EdgeInstance] Error in desired listener: {e}")
133
+
134
+ # Update cached twin data
135
+ self._update_twin_data(twin_data)
136
+
137
+ self._client.on_twin_update(self.id, on_twin_update)
138
+
139
+ # =========================================================================
140
+ # Peripheral Twin Management
141
+ # =========================================================================
142
+
143
+ async def create_peripheral_twin(
144
+ self,
145
+ name: str,
146
+ hardware_id: str,
147
+ desired_properties: Optional[dict[str, Any]] = None,
148
+ descriptors: Optional[dict[str, str]] = None,
149
+ ) -> dict[str, Any]:
150
+ """Create a new peripheral twin associated with this edge.
151
+
152
+ Args:
153
+ name: Name for the peripheral.
154
+ hardware_id: Unique hardware identifier.
155
+ desired_properties: Optional initial desired properties.
156
+ descriptors: Optional descriptors for the peripheral.
157
+
158
+ Returns:
159
+ Created peripheral twin data.
160
+
161
+ Raises:
162
+ ValueError: If peripheral with same hardware_id already exists.
163
+ """
164
+ # Check for existing peripheral with same hardware_id
165
+ existing = await self.get_peripheral_twins()
166
+ for twin in existing:
167
+ props = twin.get("properties", {}).get("desired", {})
168
+ if props.get("hardwareId") == hardware_id:
169
+ raise ValueError(
170
+ f"Peripheral with hardwareId {hardware_id} already exists"
171
+ )
172
+
173
+ payload = {
174
+ "data": {
175
+ "deviceId": self.device_id,
176
+ "tenantId": self.tenant_id,
177
+ "instanceId": self.id,
178
+ "peripheralName": name,
179
+ "hardwareId": hardware_id,
180
+ "desiredProperties": desired_properties,
181
+ "descriptors": descriptors,
182
+ }
183
+ }
184
+
185
+ result = await self._client._connection.emit(
186
+ "createPeripheralTwin", payload, callback=True
187
+ )
188
+ return result.get("twin", {}) if result else {}
189
+
190
+ async def get_peripheral_twins(self) -> list[dict[str, Any]]:
191
+ """Get all peripheral twins for this edge instance.
192
+
193
+ Returns:
194
+ List of peripheral twin data.
195
+ """
196
+ payload = {"data": {"instanceId": self.id}}
197
+ result = await self._client._connection.emit(
198
+ "getPeripheralTwins", payload, callback=True
199
+ )
200
+ return result.get("twins", []) if result else []
201
+
202
+ # =========================================================================
203
+ # Settings API
204
+ # =========================================================================
205
+
206
+ def get_settings(self) -> dict[str, Any]:
207
+ """Get settings from desired properties.
208
+
209
+ Returns:
210
+ Settings dictionary.
211
+ """
212
+ return self.properties.desired.get("settings", {})
@@ -0,0 +1,186 @@
1
+ """PeripheralInstance class for peripheral twin management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any, Callable, Optional, Set
7
+
8
+ from phystack.hub_client.instances.base_instance import BaseInstance
9
+ from phystack.hub_client.types.twin_types import TwinResponse
10
+
11
+ if TYPE_CHECKING:
12
+ from phystack.hub_client.client import PhyHubClient
13
+ from phystack.hub_client.instances.edge_instance import EdgeInstance
14
+ from phystack.hub_client.twin_messaging import TwinMessaging
15
+
16
+
17
+ class PeripheralInstance(BaseInstance):
18
+ """Peripheral twin instance with full property management."""
19
+
20
+ def __init__(
21
+ self,
22
+ twin_id: str,
23
+ device_id: str,
24
+ client: "PhyHubClient",
25
+ messaging: "TwinMessaging",
26
+ edge_instance: "EdgeInstance",
27
+ twin_data: Optional[dict[str, Any]] = None,
28
+ ) -> None:
29
+ """Initialize peripheral instance.
30
+
31
+ Args:
32
+ twin_id: ID of this peripheral twin.
33
+ device_id: ID of the parent device.
34
+ client: PhyHubClient for API calls.
35
+ messaging: TwinMessaging instance for emit/on/to.
36
+ edge_instance: Parent edge instance.
37
+ twin_data: Optional cached twin data.
38
+ """
39
+ super().__init__(twin_id, device_id, client, messaging, twin_data)
40
+ self._edge_instance = edge_instance
41
+ self._update_reported_listeners: Set[Callable[[dict[str, Any]], None]] = set()
42
+ self._update_desired_listeners: Set[Callable[[dict[str, Any]], None]] = set()
43
+ self._previous_reported: Optional[str] = None
44
+ self._previous_desired: Optional[str] = None
45
+ self._listener_setup = False
46
+
47
+ # =========================================================================
48
+ # Properties API
49
+ # =========================================================================
50
+
51
+ async def update_reported(self, properties: dict[str, Any]) -> dict[str, Any]:
52
+ """Update reported properties for this peripheral twin.
53
+
54
+ Args:
55
+ properties: Properties to update.
56
+
57
+ Returns:
58
+ Updated twin data.
59
+ """
60
+ result = await self._client.update_reported_properties(self.id, properties)
61
+ if result:
62
+ self._update_twin_data(result)
63
+ return result or {}
64
+
65
+ async def update_desired(self, properties: dict[str, Any]) -> dict[str, Any]:
66
+ """Update desired properties for this peripheral twin.
67
+
68
+ Peripherals can update their own desired properties unlike edge twins.
69
+
70
+ Args:
71
+ properties: Properties to update.
72
+
73
+ Returns:
74
+ Updated twin data.
75
+ """
76
+ payload = {
77
+ "twinId": self.id,
78
+ "data": properties,
79
+ }
80
+
81
+ result = await self._client._connection.emit(
82
+ "updatePeripheralTwinDesired", payload, callback=True
83
+ )
84
+ twin_data = result.get("twin", {}) if result else {}
85
+ if twin_data:
86
+ self._update_twin_data(twin_data)
87
+ return twin_data
88
+
89
+ def on_update_reported(
90
+ self, callback: Callable[[dict[str, Any]], None]
91
+ ) -> None:
92
+ """Subscribe to reported property changes.
93
+
94
+ Args:
95
+ callback: Handler called with new reported properties when they change.
96
+ """
97
+ self._update_reported_listeners.add(callback)
98
+ self._setup_property_listeners()
99
+
100
+ def on_update_desired(
101
+ self, callback: Callable[[dict[str, Any]], None]
102
+ ) -> None:
103
+ """Subscribe to desired property changes.
104
+
105
+ Args:
106
+ callback: Handler called with new desired properties when they change.
107
+ """
108
+ self._update_desired_listeners.add(callback)
109
+ self._setup_property_listeners()
110
+
111
+ def _setup_property_listeners(self) -> None:
112
+ """Set up twin update listener for property change detection."""
113
+ if self._listener_setup:
114
+ return
115
+ self._listener_setup = True
116
+
117
+ def on_twin_update(twin_data: dict[str, Any]) -> None:
118
+ # Only process updates for this specific peripheral
119
+ if twin_data.get("id") != self.id:
120
+ return
121
+
122
+ props = twin_data.get("properties", {})
123
+
124
+ # Check reported changes
125
+ reported = props.get("reported", {})
126
+ if reported:
127
+ reported_str = json.dumps(reported, sort_keys=True)
128
+ if self._previous_reported is None:
129
+ # First update - always trigger
130
+ self._previous_reported = reported_str
131
+ for listener in self._update_reported_listeners:
132
+ try:
133
+ listener(reported)
134
+ except Exception as e:
135
+ print(f"[PeripheralInstance] Error in reported listener: {e}")
136
+ elif reported_str != self._previous_reported:
137
+ self._previous_reported = reported_str
138
+ for listener in self._update_reported_listeners:
139
+ try:
140
+ listener(reported)
141
+ except Exception as e:
142
+ print(f"[PeripheralInstance] Error in reported listener: {e}")
143
+
144
+ # Check desired changes
145
+ desired = props.get("desired", {})
146
+ if desired:
147
+ desired_str = json.dumps(desired, sort_keys=True)
148
+ if self._previous_desired is None:
149
+ # First update - always trigger
150
+ self._previous_desired = desired_str
151
+ for listener in self._update_desired_listeners:
152
+ try:
153
+ listener(desired)
154
+ except Exception as e:
155
+ print(f"[PeripheralInstance] Error in desired listener: {e}")
156
+ elif desired_str != self._previous_desired:
157
+ self._previous_desired = desired_str
158
+ for listener in self._update_desired_listeners:
159
+ try:
160
+ listener(desired)
161
+ except Exception as e:
162
+ print(f"[PeripheralInstance] Error in desired listener: {e}")
163
+
164
+ # Update cached twin data
165
+ self._update_twin_data(twin_data)
166
+
167
+ self._client.on_twin_update(self.id, on_twin_update)
168
+
169
+ # =========================================================================
170
+ # Lifecycle
171
+ # =========================================================================
172
+
173
+ async def remove(self) -> Optional[dict[str, Any]]:
174
+ """Delete this peripheral twin.
175
+
176
+ Returns:
177
+ Deleted twin data or None.
178
+ """
179
+ payload = {
180
+ "data": {"twinId": self.id}
181
+ }
182
+
183
+ result = await self._client._connection.emit(
184
+ "deletePeripheralTwin", payload, callback=True
185
+ )
186
+ return result.get("twin") if result else None
@@ -0,0 +1,5 @@
1
+ """Services for PhyHub client."""
2
+
3
+ from phystack.hub_client.services.phyhub_connection import PhyHubConnection
4
+
5
+ __all__ = ["PhyHubConnection"]