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,287 @@
1
+ """
2
+ Twin Messaging Factory
3
+
4
+ Creates emit/on/to methods for any twin type.
5
+ Provides a consistent messaging API across all instance types.
6
+
7
+ API Design:
8
+ - emit(type, payload): Fire-and-forget broadcast to subscribers
9
+ - to(target_id).emit(type, payload): Fire-and-forget to specific twin
10
+ - to(target_id).emit(type, payload, callback): Request-response to specific twin
11
+ - on(type, callback): Listen for messages, with optional respond callback
12
+ """
13
+
14
+ import asyncio
15
+ import time
16
+ import random
17
+ import string
18
+ from dataclasses import dataclass
19
+ from typing import Any, Callable, Optional
20
+
21
+ from phystack.hub_client.types.twin_types import TwinMessageResult, TwinMessageResultStatus
22
+
23
+
24
+ REQUEST_TIMEOUT = 10.0 # seconds
25
+
26
+
27
+ @dataclass
28
+ class PendingRequest:
29
+ """Pending request awaiting response."""
30
+
31
+ future: asyncio.Future[TwinMessageResult]
32
+ timeout_handle: Optional[asyncio.TimerHandle]
33
+
34
+
35
+ class TwinMessagingContext:
36
+ """Context for twin messaging operations."""
37
+
38
+ def __init__(
39
+ self,
40
+ send_event: Callable[[str, dict[str, Any]], None],
41
+ on_twin_message: Callable[[str, Callable[[dict[str, Any]], None]], None],
42
+ twin_id: str,
43
+ device_id: str,
44
+ type_prefix: str,
45
+ ) -> None:
46
+ self.send_event = send_event
47
+ self.on_twin_message = on_twin_message
48
+ self.twin_id = twin_id
49
+ self.device_id = device_id
50
+ self.type_prefix = type_prefix
51
+
52
+
53
+ class TargetedEmitter:
54
+ """Emitter targeted at a specific twin."""
55
+
56
+ def __init__(
57
+ self,
58
+ target_twin_id: str,
59
+ send_message: Callable[[str, str, Any], None],
60
+ send_request: Callable[[str, str, Any], "asyncio.Future[TwinMessageResult]"],
61
+ ) -> None:
62
+ self._target_twin_id = target_twin_id
63
+ self._send_message = send_message
64
+ self._send_request = send_request
65
+
66
+ def emit(
67
+ self,
68
+ event_type: str,
69
+ payload: Any = None,
70
+ callback: Optional[Callable[[TwinMessageResult], None]] = None,
71
+ ) -> Optional["asyncio.Future[TwinMessageResult]"]:
72
+ """Emit an event to the targeted twin.
73
+
74
+ Args:
75
+ event_type: Type of event to emit.
76
+ payload: Data to send.
77
+ callback: Optional callback for request-response pattern.
78
+
79
+ Returns:
80
+ Future if callback provided, None otherwise.
81
+ """
82
+ if callback:
83
+ future = self._send_request(self._target_twin_id, event_type, payload)
84
+
85
+ # Add callback to be called when future completes
86
+ def on_complete(f: asyncio.Future[TwinMessageResult]) -> None:
87
+ try:
88
+ result = f.result()
89
+ callback(result)
90
+ except Exception as e:
91
+ callback(TwinMessageResult(
92
+ status=TwinMessageResultStatus.ERROR,
93
+ message=str(e),
94
+ ))
95
+
96
+ future.add_done_callback(on_complete)
97
+ return future
98
+ else:
99
+ self._send_message(self._target_twin_id, event_type, payload)
100
+ return None
101
+
102
+
103
+ class TwinMessaging:
104
+ """Twin messaging methods (emit, on, to)."""
105
+
106
+ def __init__(self, context: TwinMessagingContext) -> None:
107
+ self._context = context
108
+ self._pending_requests: dict[str, PendingRequest] = {}
109
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
110
+
111
+ # Set up response listener
112
+ self._setup_response_listener()
113
+
114
+ def _get_loop(self) -> asyncio.AbstractEventLoop:
115
+ """Get the event loop, creating if necessary."""
116
+ if self._loop is None:
117
+ try:
118
+ self._loop = asyncio.get_running_loop()
119
+ except RuntimeError:
120
+ self._loop = asyncio.new_event_loop()
121
+ return self._loop
122
+
123
+ def _generate_request_id(self) -> str:
124
+ """Generate a unique request ID."""
125
+ random_part = "".join(random.choices(string.ascii_lowercase + string.digits, k=9))
126
+ return f"{int(time.time() * 1000)}-{random_part}"
127
+
128
+ def _build_message(self, msg_type: str, data: Any) -> dict[str, Any]:
129
+ """Build message payload with metadata."""
130
+ return {
131
+ "type": f"{self._context.type_prefix}:{msg_type}",
132
+ "sourceTwinId": self._context.twin_id,
133
+ "sourceDeviceId": self._context.device_id,
134
+ "data": data,
135
+ }
136
+
137
+ def _send_message(self, target_twin_id: str, msg_type: str, payload: Any) -> None:
138
+ """Send fire-and-forget event."""
139
+ self._context.send_event(target_twin_id, self._build_message(msg_type, payload))
140
+
141
+ def _send_request(
142
+ self,
143
+ target_twin_id: str,
144
+ msg_type: str,
145
+ payload: Any,
146
+ ) -> asyncio.Future[TwinMessageResult]:
147
+ """Send request and wait for response."""
148
+ request_id = self._generate_request_id()
149
+ loop = self._get_loop()
150
+ future: asyncio.Future[TwinMessageResult] = loop.create_future()
151
+
152
+ def on_timeout() -> None:
153
+ if request_id in self._pending_requests:
154
+ del self._pending_requests[request_id]
155
+ if not future.done():
156
+ future.set_exception(
157
+ TimeoutError(f"Request timed out after {REQUEST_TIMEOUT}s")
158
+ )
159
+
160
+ timeout_handle = loop.call_later(REQUEST_TIMEOUT, on_timeout)
161
+ self._pending_requests[request_id] = PendingRequest(
162
+ future=future,
163
+ timeout_handle=timeout_handle,
164
+ )
165
+
166
+ # Send with requestId embedded
167
+ request_payload = {**(payload or {}), "requestId": request_id}
168
+ self._context.send_event(
169
+ target_twin_id,
170
+ self._build_message(msg_type, request_payload),
171
+ )
172
+
173
+ return future
174
+
175
+ def _setup_response_listener(self) -> None:
176
+ """Set up response listener for request-response pattern."""
177
+
178
+ def handle_response(payload: dict[str, Any]) -> None:
179
+ message_type = (payload.get("data") or {}).get("type") or payload.get("type")
180
+ if message_type == f"{self._context.type_prefix}:response":
181
+ response_data = (payload.get("data") or {}).get("data") or payload.get("data")
182
+ request_id = (response_data or {}).get("requestId")
183
+
184
+ pending = self._pending_requests.get(request_id)
185
+ if pending:
186
+ if pending.timeout_handle:
187
+ pending.timeout_handle.cancel()
188
+ del self._pending_requests[request_id]
189
+
190
+ # Extract result without requestId
191
+ result_data = {k: v for k, v in (response_data or {}).items() if k != "requestId"}
192
+ result = TwinMessageResult(
193
+ status=TwinMessageResultStatus(result_data.get("status", "success")),
194
+ message=result_data.get("message", ""),
195
+ data=result_data.get("data"),
196
+ )
197
+
198
+ if not pending.future.done():
199
+ pending.future.set_result(result)
200
+
201
+ self._context.on_twin_message(self._context.twin_id, handle_response)
202
+
203
+ def emit(self, event_type: str, payload: Any = None) -> None:
204
+ """Fire-and-forget broadcast to subscribers.
205
+
206
+ Args:
207
+ event_type: Type of event to emit.
208
+ payload: Data to send.
209
+ """
210
+ self._send_message(self._context.twin_id, event_type, payload)
211
+
212
+ def on(
213
+ self,
214
+ event_type: str,
215
+ callback: Callable[[Any, Optional[Callable[[TwinMessageResult], None]]], None],
216
+ ) -> None:
217
+ """Listen for events, with optional respond callback for request-response.
218
+
219
+ Args:
220
+ event_type: Type of event to listen for.
221
+ callback: Handler function (data, respond?) => None
222
+ """
223
+ expected_type = f"{self._context.type_prefix}:{event_type}"
224
+
225
+ def handler(payload: dict[str, Any]) -> None:
226
+ message_type = (payload.get("data") or {}).get("type") or payload.get("type")
227
+
228
+ if message_type == expected_type:
229
+ message_data = (payload.get("data") or {}).get("data") or payload.get("data")
230
+ request_id = (message_data or {}).get("requestId") if message_data else None
231
+ source_twin_id = payload.get("sourceTwinId")
232
+
233
+ # Create respond callback if this is a request
234
+ respond: Optional[Callable[[TwinMessageResult], None]] = None
235
+ if request_id and source_twin_id:
236
+
237
+ def respond_fn(result: TwinMessageResult) -> None:
238
+ response_data = {
239
+ "status": result.status.value,
240
+ "message": result.message,
241
+ "requestId": request_id,
242
+ }
243
+ if result.data:
244
+ response_data["data"] = result.data
245
+ self._context.send_event(
246
+ source_twin_id,
247
+ self._build_message("response", response_data),
248
+ )
249
+
250
+ respond = respond_fn
251
+
252
+ # Strip requestId from payload
253
+ if message_data and isinstance(message_data, dict):
254
+ clean_data = {k: v for k, v in message_data.items() if k != "requestId"}
255
+ else:
256
+ clean_data = message_data
257
+
258
+ callback(clean_data, respond)
259
+
260
+ self._context.on_twin_message(self._context.twin_id, handler)
261
+
262
+ def to(self, target_twin_id: str) -> TargetedEmitter:
263
+ """Target a specific twin for messaging.
264
+
265
+ Args:
266
+ target_twin_id: ID of twin to target.
267
+
268
+ Returns:
269
+ TargetedEmitter for the specified twin.
270
+ """
271
+ return TargetedEmitter(
272
+ target_twin_id=target_twin_id,
273
+ send_message=self._send_message,
274
+ send_request=self._send_request,
275
+ )
276
+
277
+
278
+ def create_twin_messaging(context: TwinMessagingContext) -> TwinMessaging:
279
+ """Create messaging methods (emit, on, to) for a twin instance.
280
+
281
+ Args:
282
+ context: Configuration context.
283
+
284
+ Returns:
285
+ TwinMessaging instance.
286
+ """
287
+ return TwinMessaging(context)
@@ -0,0 +1,158 @@
1
+ """Twin Registry for managing and caching twin instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Optional
6
+
7
+ from phystack.hub_client.instances.edge_instance import EdgeInstance
8
+ from phystack.hub_client.instances.peripheral_instance import PeripheralInstance
9
+ from phystack.hub_client.twin_messaging import (
10
+ TwinMessaging,
11
+ TwinMessagingContext,
12
+ create_twin_messaging,
13
+ )
14
+ from phystack.hub_client.types.twin_types import TwinTypeEnum
15
+
16
+ if TYPE_CHECKING:
17
+ from phystack.hub_client.client import PhyHubClient
18
+
19
+
20
+ class TwinRegistry:
21
+ """Registry to create, store, and retrieve twin instances."""
22
+
23
+ def __init__(self, client: "PhyHubClient") -> None:
24
+ """Initialize twin registry.
25
+
26
+ Args:
27
+ client: PhyHubClient for API calls.
28
+ """
29
+ self._client = client
30
+ self._edge_instance: Optional[EdgeInstance] = None
31
+ self._peripheral_instances: dict[str, PeripheralInstance] = {}
32
+
33
+ async def get_edge_instance(self) -> Optional[EdgeInstance]:
34
+ """Get or create the edge instance.
35
+
36
+ Returns:
37
+ EdgeInstance or None if not connected.
38
+ """
39
+ if self._edge_instance:
40
+ return self._edge_instance
41
+
42
+ instance_id = self._client._instance_id
43
+ if not instance_id:
44
+ return None
45
+
46
+ # Fetch twin data
47
+ twin_data = await self._client.get_twin_by_id(instance_id)
48
+ if not twin_data:
49
+ return None
50
+
51
+ device_id = self._client._connection.device_id if self._client._connection else ""
52
+
53
+ # Create messaging context with edge prefix
54
+ messaging = self._create_messaging(
55
+ twin_id=instance_id,
56
+ device_id=device_id,
57
+ type_prefix="edgeInstance",
58
+ )
59
+
60
+ # Subscribe to updates
61
+ await self._client.subscribe_twin(instance_id)
62
+
63
+ self._edge_instance = EdgeInstance(
64
+ twin_id=instance_id,
65
+ device_id=device_id,
66
+ client=self._client,
67
+ messaging=messaging,
68
+ twin_data=twin_data,
69
+ )
70
+
71
+ return self._edge_instance
72
+
73
+ async def get_peripheral_instance(
74
+ self, twin_id: str
75
+ ) -> Optional[PeripheralInstance]:
76
+ """Get or create a peripheral instance by twin ID.
77
+
78
+ Args:
79
+ twin_id: ID of the peripheral twin.
80
+
81
+ Returns:
82
+ PeripheralInstance or None if not found.
83
+
84
+ Raises:
85
+ ValueError: If twin is not a peripheral type.
86
+ """
87
+ # Return cached if exists
88
+ if twin_id in self._peripheral_instances:
89
+ return self._peripheral_instances[twin_id]
90
+
91
+ # Need edge instance for peripheral
92
+ edge = await self.get_edge_instance()
93
+ if not edge:
94
+ raise ValueError("Edge instance not available")
95
+
96
+ # Fetch twin data
97
+ twin_data = await self._client.get_twin_by_id(twin_id)
98
+ if not twin_data:
99
+ raise ValueError(f"Peripheral twin {twin_id} not found")
100
+
101
+ # Validate type
102
+ twin_type = twin_data.get("type")
103
+ if twin_type != TwinTypeEnum.Peripheral.value:
104
+ raise ValueError(
105
+ f"Twin {twin_id} is not a peripheral (type={twin_type})"
106
+ )
107
+
108
+ # Create messaging with peripheral prefix
109
+ messaging = self._create_messaging(
110
+ twin_id=twin_id,
111
+ device_id=edge.device_id,
112
+ type_prefix=f"peripheralInstance:{twin_id}",
113
+ )
114
+
115
+ # Subscribe to updates
116
+ await self._client.subscribe_twin(twin_id)
117
+
118
+ instance = PeripheralInstance(
119
+ twin_id=twin_id,
120
+ device_id=edge.device_id,
121
+ client=self._client,
122
+ messaging=messaging,
123
+ edge_instance=edge,
124
+ twin_data=twin_data,
125
+ )
126
+
127
+ self._peripheral_instances[twin_id] = instance
128
+ return instance
129
+
130
+ def _create_messaging(
131
+ self,
132
+ twin_id: str,
133
+ device_id: str,
134
+ type_prefix: str,
135
+ ) -> TwinMessaging:
136
+ """Create a TwinMessaging instance for an instance type.
137
+
138
+ Args:
139
+ twin_id: ID of the twin.
140
+ device_id: ID of the device.
141
+ type_prefix: Message type prefix (e.g., 'edgeInstance').
142
+
143
+ Returns:
144
+ TwinMessaging instance.
145
+ """
146
+ context = TwinMessagingContext(
147
+ send_event=self._client._send_event,
148
+ on_twin_message=self._client.on_twin_message,
149
+ twin_id=twin_id,
150
+ device_id=device_id,
151
+ type_prefix=type_prefix,
152
+ )
153
+ return create_twin_messaging(context)
154
+
155
+ def clear(self) -> None:
156
+ """Clear all cached instances."""
157
+ self._edge_instance = None
158
+ self._peripheral_instances.clear()
@@ -0,0 +1,15 @@
1
+ """Type definitions for PhyHub client."""
2
+
3
+ from phystack.hub_client.types.twin_types import (
4
+ TwinMessageResult,
5
+ TwinMessageResultStatus,
6
+ TwinResponse,
7
+ EventPayload,
8
+ )
9
+
10
+ __all__ = [
11
+ "TwinMessageResult",
12
+ "TwinMessageResultStatus",
13
+ "TwinResponse",
14
+ "EventPayload",
15
+ ]
@@ -0,0 +1,136 @@
1
+ """Twin type definitions matching the TypeScript implementation."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Any, Callable, Optional
6
+
7
+
8
+ class TwinTypeEnum(str, Enum):
9
+ """Twin type enumeration matching npm hub-client."""
10
+
11
+ Device = "Device"
12
+ Screen = "Screen"
13
+ Edge = "Edge"
14
+ Cloud = "Cloud"
15
+ Web = "Web"
16
+ Peripheral = "Peripheral"
17
+
18
+
19
+ class TwinStatusEnum(str, Enum):
20
+ """Twin status enumeration matching npm hub-client."""
21
+
22
+ Offline = "Offline"
23
+ Online = "Online"
24
+ Starting = "Starting"
25
+ Exited = "Exited"
26
+ ImageNotFound = "ImageNotFound"
27
+
28
+
29
+ class TwinMessageResultStatus(str, Enum):
30
+ """Status codes for twin message results."""
31
+
32
+ SUCCESS = "success"
33
+ ERROR = "error"
34
+ WARNING = "warning"
35
+
36
+
37
+ @dataclass
38
+ class TwinMessageResult:
39
+ """Result of a twin message operation."""
40
+
41
+ status: TwinMessageResultStatus
42
+ message: str
43
+ data: Optional[dict[str, Any]] = None
44
+
45
+
46
+ @dataclass
47
+ class TwinProperties:
48
+ """Twin properties structure with desired and reported."""
49
+
50
+ desired: dict[str, Any] = field(default_factory=dict)
51
+ reported: dict[str, Any] = field(default_factory=dict)
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: dict[str, Any]) -> "TwinProperties":
55
+ """Create from dictionary."""
56
+ return cls(
57
+ desired=data.get("desired", {}),
58
+ reported=data.get("reported", {}),
59
+ )
60
+
61
+
62
+ @dataclass
63
+ class TwinResponse:
64
+ """Response containing twin data."""
65
+
66
+ id: str
67
+ device_id: str
68
+ tenant_id: str
69
+ version: int
70
+ status: str
71
+ type: str
72
+ properties: dict[str, Any] = field(default_factory=dict)
73
+ descriptors: dict[str, str] = field(default_factory=dict)
74
+ notes: str = ""
75
+ created_at: Optional[str] = None
76
+ updated_at: Optional[str] = None
77
+
78
+ def get_properties(self) -> TwinProperties:
79
+ """Get typed properties."""
80
+ return TwinProperties.from_dict(self.properties)
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict[str, Any]) -> "TwinResponse":
84
+ """Create from API response dictionary."""
85
+ return cls(
86
+ id=data.get("id", ""),
87
+ device_id=data.get("deviceId", ""),
88
+ tenant_id=data.get("tenantId", ""),
89
+ version=data.get("version", 0),
90
+ status=data.get("status", ""),
91
+ type=data.get("type", ""),
92
+ properties=data.get("properties", {}),
93
+ descriptors=data.get("descriptors", {}),
94
+ notes=data.get("notes", ""),
95
+ created_at=data.get("createdAt"),
96
+ updated_at=data.get("updatedAt"),
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class EventPayload:
102
+ """Payload structure for twin events."""
103
+
104
+ twin_id: Optional[str] = None
105
+ source_twin_id: Optional[str] = None
106
+ source_device_id: Optional[str] = None
107
+ data: Optional[dict[str, Any]] = None
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ """Convert to dictionary for Socket.IO transmission."""
111
+ result: dict[str, Any] = {}
112
+ if self.twin_id:
113
+ result["twinId"] = self.twin_id
114
+ if self.source_twin_id:
115
+ result["sourceTwinId"] = self.source_twin_id
116
+ if self.source_device_id:
117
+ result["sourceDeviceId"] = self.source_device_id
118
+ if self.data:
119
+ result["data"] = self.data
120
+ return result
121
+
122
+ @classmethod
123
+ def from_dict(cls, data: dict[str, Any]) -> "EventPayload":
124
+ """Create from Socket.IO payload dictionary."""
125
+ return cls(
126
+ twin_id=data.get("twinId"),
127
+ source_twin_id=data.get("sourceTwinId"),
128
+ source_device_id=data.get("sourceDeviceId"),
129
+ data=data.get("data"),
130
+ )
131
+
132
+
133
+ # Type aliases for callbacks
134
+ MessageCallback = Callable[[dict[str, Any]], None]
135
+ RespondCallback = Callable[[TwinMessageResult], None]
136
+ EventCallback = Callable[[dict[str, Any], Optional[RespondCallback]], None]