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.
- phystack/hub_client/__init__.py +90 -0
- phystack/hub_client/client.py +701 -0
- phystack/hub_client/instances/__init__.py +11 -0
- phystack/hub_client/instances/base_instance.py +225 -0
- phystack/hub_client/instances/edge_instance.py +212 -0
- phystack/hub_client/instances/peripheral_instance.py +186 -0
- phystack/hub_client/services/__init__.py +5 -0
- phystack/hub_client/services/phyhub_connection.py +272 -0
- phystack/hub_client/services/webrtc/__init__.py +23 -0
- phystack/hub_client/services/webrtc/data_channel.py +527 -0
- phystack/hub_client/services/webrtc/media_stream.py +571 -0
- phystack/hub_client/twin_messaging.py +287 -0
- phystack/hub_client/twin_registry.py +158 -0
- phystack/hub_client/types/__init__.py +15 -0
- phystack/hub_client/types/twin_types.py +136 -0
- phystack_hub_client-0.2.0.dist-info/METADATA +174 -0
- phystack_hub_client-0.2.0.dist-info/RECORD +18 -0
- phystack_hub_client-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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]
|