uns-kit 0.0.4__py3-none-any.whl → 0.0.5__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.
uns_kit/__init__.py CHANGED
@@ -2,6 +2,10 @@ from .topic_builder import TopicBuilder
2
2
  from .packet import UnsPacket, DataPayload, TablePayload
3
3
  from .client import UnsMqttClient
4
4
  from .config import UnsConfig
5
+ from .status_monitor import StatusMonitor
6
+ from .uns_mqtt_proxy import UnsMqttProxy, MessageMode
7
+ from .proxy_process import UnsProxyProcess
8
+ from .version import __version__
5
9
 
6
10
  __all__ = [
7
11
  "TopicBuilder",
@@ -10,4 +14,9 @@ __all__ = [
10
14
  "TablePayload",
11
15
  "UnsMqttClient",
12
16
  "UnsConfig",
17
+ "StatusMonitor",
18
+ "UnsMqttProxy",
19
+ "MessageMode",
20
+ "UnsProxyProcess",
21
+ "__version__",
13
22
  ]
uns_kit/client.py CHANGED
@@ -3,15 +3,22 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import contextlib
5
5
  from contextlib import asynccontextmanager
6
+ from datetime import datetime, timezone
7
+ import uuid
8
+ import socket
6
9
  from typing import AsyncIterator, List, Optional
7
10
 
8
- from asyncio_mqtt import Client, MqttError, Message
11
+ from asyncio_mqtt import Client, MqttError, Message, Will, Topic
12
+ from paho.mqtt.client import MQTTv5, MQTTv311
9
13
 
10
14
  from .packet import UnsPacket
11
15
  from .topic_builder import TopicBuilder
12
16
 
13
17
 
14
18
  class UnsMqttClient:
19
+ _exception_handler_installed = False
20
+ _exception_handler_loop_id: int | None = None
21
+
15
22
  def __init__(
16
23
  self,
17
24
  host: str,
@@ -26,45 +33,135 @@ class UnsMqttClient:
26
33
  clean_session: bool = True,
27
34
  reconnect_interval: float = 2.0,
28
35
  max_reconnect_interval: float = 30.0,
36
+ instance_name: Optional[str] = None,
37
+ publisher_active: Optional[bool] = None,
38
+ subscriber_active: Optional[bool] = None,
39
+ stats_interval: float = 60.0,
40
+ enable_status: bool = True,
29
41
  ):
30
42
  self.host = host
31
43
  self.port = port
32
- self.username = username
33
- self.password = password
34
- self.client_id = client_id
44
+ self.username = username if username not in ("", None) else None
45
+ self.password = password if password not in ("", None) else None
46
+ if client_id:
47
+ self.client_id = client_id
48
+ else:
49
+ base = topic_builder.process_name
50
+ if instance_name:
51
+ base = f"{base}-{instance_name}"
52
+ self.client_id = f"{base}-{uuid.uuid4().hex[:8]}"
35
53
  self.tls = tls
36
54
  self.keepalive = keepalive
37
55
  self.clean_session = clean_session
38
56
  self.reconnect_interval = reconnect_interval
39
57
  self.max_reconnect_interval = max_reconnect_interval
40
58
  self.topic_builder = topic_builder
59
+ self.instance_name = instance_name
60
+ self.publisher_active = publisher_active
61
+ self.subscriber_active = subscriber_active
62
+ self.stats_interval = stats_interval
63
+ self.enable_status = enable_status
41
64
  self._client: Optional[Client] = None
42
65
  self._status_task: Optional[asyncio.Task] = None
66
+ self._stats_task: Optional[asyncio.Task] = None
43
67
  self._connected = asyncio.Event()
44
68
  self._closing = False
45
69
  self._connect_lock = asyncio.Lock()
70
+ self._published_message_count = 0
71
+ self._published_message_bytes = 0
72
+ self._subscribed_message_count = 0
73
+ self._subscribed_message_bytes = 0
74
+
75
+ if self.instance_name:
76
+ self.status_topic = self.topic_builder.instance_status_topic(self.instance_name)
77
+ else:
78
+ self.status_topic = self.topic_builder.process_status_topic
79
+
80
+ def _install_exception_handler(self) -> None:
81
+ loop = asyncio.get_running_loop()
82
+ loop_id = id(loop)
83
+ if UnsMqttClient._exception_handler_installed and UnsMqttClient._exception_handler_loop_id == loop_id:
84
+ return
85
+ original = loop.get_exception_handler()
86
+
87
+ def handler(loop: asyncio.AbstractEventLoop, context: dict) -> None:
88
+ exc = context.get("exception")
89
+ message = context.get("message", "")
90
+ if isinstance(exc, MqttError) and "Unexpected disconnect" in str(exc):
91
+ # Swallow noisy disconnect futures; reconnect logic handles it.
92
+ return
93
+ if isinstance(exc, MqttError) and "Future exception was never retrieved" in message:
94
+ return
95
+ if original:
96
+ original(loop, context)
97
+ else:
98
+ loop.default_exception_handler(context)
99
+
100
+ loop.set_exception_handler(handler)
101
+ UnsMqttClient._exception_handler_installed = True
102
+ UnsMqttClient._exception_handler_loop_id = loop_id
46
103
 
47
104
  async def _connect_once(self) -> None:
48
- will = {
49
- "topic": f"{self.topic_builder.process_status_topic}alive",
50
- "payload": b"",
51
- "qos": 0,
52
- "retain": True,
53
- }
54
- client = Client(
55
- hostname=self.host,
56
- port=self.port,
57
- username=self.username,
58
- password=self.password,
59
- client_id=self.client_id,
60
- keepalive=self.keepalive,
61
- clean_session=self.clean_session,
62
- tls=self.tls,
63
- will=will,
105
+ await self._probe_socket()
106
+ will = Will(
107
+ topic=f"{self.status_topic}alive",
108
+ payload=b"",
109
+ qos=0,
110
+ retain=True,
64
111
  )
65
- await client.connect()
66
- self._client = client
67
- self._connected.set()
112
+ base_kwargs = {
113
+ "hostname": self.host,
114
+ "port": self.port,
115
+ "username": self.username,
116
+ "password": self.password,
117
+ "client_id": self.client_id,
118
+ "keepalive": self.keepalive,
119
+ "will": will,
120
+ }
121
+ if self.tls:
122
+ try:
123
+ import ssl
124
+ base_kwargs["tls_context"] = ssl.create_default_context()
125
+ except Exception:
126
+ pass
127
+
128
+ last_error: Exception | None = None
129
+ # Prefer MQTT 3.1.1 for widest broker compatibility (some brokers
130
+ # allow connect with MQTTv5 but then drop subscriptions unexpectedly).
131
+ for protocol in (MQTTv311, MQTTv5):
132
+ kwargs = dict(base_kwargs)
133
+ kwargs["protocol"] = protocol
134
+ if protocol == MQTTv5:
135
+ kwargs["clean_start"] = self.clean_session
136
+ kwargs.pop("clean_session", None)
137
+ else:
138
+ kwargs["clean_session"] = self.clean_session
139
+ kwargs.pop("clean_start", None)
140
+ try:
141
+ try:
142
+ client = Client(**kwargs)
143
+ except TypeError:
144
+ kwargs.pop("tls_context", None)
145
+ client = Client(**kwargs)
146
+ await client.connect()
147
+ self._client = client
148
+ self._connected.set()
149
+ return
150
+ except Exception as exc:
151
+ last_error = exc
152
+ continue
153
+ if last_error:
154
+ raise last_error
155
+
156
+ async def _probe_socket(self) -> None:
157
+ try:
158
+ loop = asyncio.get_event_loop()
159
+ await loop.run_in_executor(
160
+ None,
161
+ lambda: socket.create_connection((self.host, self.port or 1883), timeout=2),
162
+ )
163
+ except Exception as exc:
164
+ raise MqttError(f"Cannot reach MQTT broker at {self.host}:{self.port or 1883}") from exc
68
165
 
69
166
  async def _ensure_connected(self) -> None:
70
167
  if self._connected.is_set():
@@ -72,17 +169,31 @@ class UnsMqttClient:
72
169
  async with self._connect_lock:
73
170
  if self._connected.is_set():
74
171
  return
172
+ self._install_exception_handler()
75
173
  backoff = self.reconnect_interval
76
174
  while not self._closing:
77
175
  try:
78
176
  await self._connect_once()
79
- if not self._status_task or self._status_task.done():
80
- self._status_task = asyncio.create_task(self._publish_status_loop())
177
+ if self.enable_status:
178
+ if not self._status_task or self._status_task.done():
179
+ self._status_task = asyncio.create_task(self._publish_status_loop())
180
+ self._status_task.add_done_callback(self._handle_task_error)
181
+ if not self._stats_task or self._stats_task.done():
182
+ self._stats_task = asyncio.create_task(self._publish_stats_loop())
183
+ self._stats_task.add_done_callback(self._handle_task_error)
81
184
  return
82
185
  except MqttError:
83
186
  await asyncio.sleep(backoff)
84
187
  backoff = min(backoff * 2, self.max_reconnect_interval)
85
188
 
189
+ def _handle_task_error(self, task: asyncio.Task) -> None:
190
+ try:
191
+ task.exception()
192
+ except asyncio.CancelledError:
193
+ return
194
+ except Exception:
195
+ self._connected.clear()
196
+
86
197
  async def connect(self) -> None:
87
198
  await self._ensure_connected()
88
199
 
@@ -92,6 +203,10 @@ class UnsMqttClient:
92
203
  self._status_task.cancel()
93
204
  with contextlib.suppress(asyncio.CancelledError):
94
205
  await self._status_task
206
+ if self._stats_task:
207
+ self._stats_task.cancel()
208
+ with contextlib.suppress(asyncio.CancelledError):
209
+ await self._stats_task
95
210
  if self._client:
96
211
  with contextlib.suppress(Exception):
97
212
  await self._client.disconnect()
@@ -102,6 +217,9 @@ class UnsMqttClient:
102
217
  for attempt in range(2):
103
218
  try:
104
219
  assert self._client
220
+ payload_bytes = payload.encode() if isinstance(payload, str) else payload
221
+ self._published_message_count += 1
222
+ self._published_message_bytes += len(payload_bytes)
105
223
  await self._client.publish(topic, payload, qos=qos, retain=retain)
106
224
  return
107
225
  except MqttError:
@@ -119,14 +237,30 @@ class UnsMqttClient:
119
237
  async def messages(self, topics: str | List[str]) -> AsyncIterator[AsyncIterator[Message]]:
120
238
  await self._ensure_connected()
121
239
  assert self._client
122
- manager = self._client.filtered_messages(topics) if isinstance(topics, str) else self._client.unfiltered_messages()
123
- async with manager as messages:
124
- if isinstance(topics, str):
125
- await self._client.subscribe(topics)
126
- else:
127
- for t in topics:
128
- await self._client.subscribe(t)
129
- yield messages
240
+ async with self._client.messages() as messages:
241
+ try:
242
+ if isinstance(topics, str):
243
+ await self._client.subscribe(topics)
244
+ else:
245
+ for t in topics:
246
+ await self._client.subscribe(t)
247
+ except MqttError:
248
+ self._connected.clear()
249
+ raise
250
+
251
+ async def wrapped() -> AsyncIterator[Message]:
252
+ try:
253
+ async for msg in messages:
254
+ self._subscribed_message_count += 1
255
+ self._subscribed_message_bytes += len(msg.payload or b"")
256
+ yield msg
257
+ except MqttError:
258
+ self._connected.clear()
259
+ return
260
+ except asyncio.CancelledError:
261
+ return
262
+
263
+ yield wrapped()
130
264
 
131
265
  async def resilient_messages(self, topics: str | List[str]) -> AsyncIterator[Message]:
132
266
  """
@@ -142,17 +276,74 @@ class UnsMqttClient:
142
276
  self._connected.clear()
143
277
  await asyncio.sleep(self.reconnect_interval)
144
278
  continue
279
+ except Exception:
280
+ self._connected.clear()
281
+ await asyncio.sleep(self.reconnect_interval)
282
+ continue
145
283
 
146
284
  async def _publish_status_loop(self) -> None:
147
- uptime_topic = f"{self.topic_builder.process_status_topic}uptime"
148
- alive_topic = f"{self.topic_builder.process_status_topic}alive"
285
+ uptime_topic = f"{self.status_topic}uptime"
286
+ alive_topic = f"{self.status_topic}alive"
287
+ publisher_topic = f"{self.status_topic}t-publisher-active"
288
+ subscriber_topic = f"{self.status_topic}t-subscriber-active"
149
289
  start = asyncio.get_event_loop().time()
150
290
  try:
151
291
  while not self._closing:
152
- now = asyncio.get_event_loop().time()
153
- uptime_minutes = int((now - start) / 60)
154
- await self.publish_raw(alive_topic, b"", qos=0, retain=True)
155
- await self.publish_raw(uptime_topic, str(uptime_minutes).encode(), qos=0, retain=True)
292
+ try:
293
+ now = asyncio.get_event_loop().time()
294
+ uptime_minutes = int((now - start) / 60)
295
+ time = datetime.now(timezone.utc)
296
+ alive_packet = UnsPacket.data(value=1, uom="bit", time=time)
297
+ uptime_packet = UnsPacket.data(value=uptime_minutes, uom="min", time=time)
298
+ await self.publish_raw(alive_topic, UnsPacket.to_json(alive_packet), qos=0, retain=False)
299
+ await self.publish_raw(uptime_topic, UnsPacket.to_json(uptime_packet), qos=0, retain=False)
300
+ if self.publisher_active is not None:
301
+ publisher_packet = UnsPacket.data(value=1 if self.publisher_active else 0, uom="bit", time=time)
302
+ await self.publish_raw(publisher_topic, UnsPacket.to_json(publisher_packet), qos=0, retain=False)
303
+ if self.subscriber_active is not None:
304
+ subscriber_packet = UnsPacket.data(value=1 if self.subscriber_active else 0, uom="bit", time=time)
305
+ await self.publish_raw(subscriber_topic, UnsPacket.to_json(subscriber_packet), qos=0, retain=False)
306
+ except MqttError:
307
+ self._connected.clear()
308
+ except Exception:
309
+ self._connected.clear()
156
310
  await asyncio.sleep(10)
157
311
  except asyncio.CancelledError:
158
312
  pass
313
+
314
+ async def _publish_stats_loop(self) -> None:
315
+ published_count_topic = f"{self.status_topic}published-message-count"
316
+ published_bytes_topic = f"{self.status_topic}published-message-bytes"
317
+ subscribed_count_topic = f"{self.status_topic}subscribed-message-count"
318
+ subscribed_bytes_topic = f"{self.status_topic}subscribed-message-bytes"
319
+ try:
320
+ while not self._closing:
321
+ try:
322
+ time = datetime.now(timezone.utc)
323
+ published_count_packet = UnsPacket.data(value=self._published_message_count, time=time)
324
+ published_bytes_packet = UnsPacket.data(
325
+ value=round(self._published_message_bytes / 1024),
326
+ uom="kB",
327
+ time=time,
328
+ )
329
+ subscribed_count_packet = UnsPacket.data(value=self._subscribed_message_count, time=time)
330
+ subscribed_bytes_packet = UnsPacket.data(
331
+ value=round(self._subscribed_message_bytes / 1024),
332
+ uom="kB",
333
+ time=time,
334
+ )
335
+ await self.publish_raw(published_count_topic, UnsPacket.to_json(published_count_packet), qos=0, retain=False)
336
+ await self.publish_raw(published_bytes_topic, UnsPacket.to_json(published_bytes_packet), qos=0, retain=False)
337
+ await self.publish_raw(subscribed_count_topic, UnsPacket.to_json(subscribed_count_packet), qos=0, retain=False)
338
+ await self.publish_raw(subscribed_bytes_topic, UnsPacket.to_json(subscribed_bytes_packet), qos=0, retain=False)
339
+ self._published_message_count = 0
340
+ self._published_message_bytes = 0
341
+ self._subscribed_message_count = 0
342
+ self._subscribed_message_bytes = 0
343
+ except MqttError:
344
+ self._connected.clear()
345
+ except Exception:
346
+ self._connected.clear()
347
+ await asyncio.sleep(self.stats_interval)
348
+ except asyncio.CancelledError:
349
+ pass
uns_kit/config.py CHANGED
@@ -6,27 +6,85 @@ from pathlib import Path
6
6
  from typing import List, Optional
7
7
 
8
8
  from .topic_builder import TopicBuilder
9
+ from .version import __version__
9
10
 
10
11
 
11
12
  @dataclass
12
13
  class UnsConfig:
13
- host: str
14
- port: Optional[int] = None
15
- username: Optional[str] = None
16
- password: Optional[str] = None
17
- client_id: Optional[str] = None
18
- tls: bool = False
14
+ # MQTT infra (matches TS nested config.infra)
15
+ infra_host: str
16
+ infra_port: Optional[int] = None
17
+ infra_username: Optional[str] = None
18
+ infra_password: Optional[str] = None
19
+ infra_client_id: Optional[str] = None
20
+ infra_tls: bool = False
19
21
  keepalive: int = 60
20
22
  clean_session: bool = True
21
23
  mqtt_sub_to_topics: Optional[List[str]] = None
22
24
  package_name: str = "uns-kit"
23
- package_version: str = "0.0.1"
25
+ package_version: str = __version__
24
26
  process_name: str = "uns-process"
25
27
 
26
28
  @staticmethod
27
29
  def load(path: Path) -> "UnsConfig":
28
30
  data = json.loads(path.read_text())
29
- return UnsConfig(**data)
31
+
32
+ if "infra" not in data or "uns" not in data:
33
+ raise ValueError("config.json must include 'infra' and 'uns' sections (TS-style config)")
34
+
35
+ infra = data.get("infra", {}) or {}
36
+ uns_section = data.get("uns", {}) or {}
37
+
38
+ proto = (infra.get("protocol") or "").lower()
39
+ tls = proto in ("mqtts", "ssl", "wss")
40
+
41
+ host = infra.get("host") or (infra.get("hosts") or [None])[0]
42
+ if not host:
43
+ raise ValueError("infra.host (or hosts[0]) is required")
44
+
45
+ return UnsConfig(
46
+ infra_host=host,
47
+ infra_port=infra.get("port"),
48
+ infra_username=_none_if_empty(infra.get("username")),
49
+ infra_password=_none_if_empty(infra.get("password")),
50
+ infra_client_id=_none_if_empty(infra.get("clientId")),
51
+ infra_tls=infra.get("tls") if infra.get("tls") is not None else tls,
52
+ mqtt_sub_to_topics=infra.get("mqttSubToTopics"),
53
+ keepalive=infra.get("keepalive", 60),
54
+ clean_session=infra.get("clean", True),
55
+ package_name=uns_section.get("packageName", "uns-kit"),
56
+ package_version=uns_section.get("packageVersion") or __version__,
57
+ process_name=uns_section.get("processName", "uns-process"),
58
+ )
30
59
 
31
60
  def topic_builder(self) -> TopicBuilder:
32
61
  return TopicBuilder(self.package_name, self.package_version, self.process_name)
62
+
63
+ # Convenience accessors for MQTT client creation
64
+ @property
65
+ def host(self) -> str:
66
+ return self.infra_host
67
+
68
+ @property
69
+ def port(self) -> Optional[int]:
70
+ return self.infra_port
71
+
72
+ @property
73
+ def username(self) -> Optional[str]:
74
+ return self.infra_username
75
+
76
+ @property
77
+ def password(self) -> Optional[str]:
78
+ return self.infra_password
79
+
80
+ @property
81
+ def client_id(self) -> Optional[str]:
82
+ return self.infra_client_id
83
+
84
+ @property
85
+ def tls(self) -> bool:
86
+ return self.infra_tls
87
+
88
+
89
+ def _none_if_empty(value: Optional[str]) -> Optional[str]:
90
+ return None if value == "" else value
uns_kit/packet.py CHANGED
@@ -1,13 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass, asdict
3
+ from dataclasses import dataclass
4
4
  from datetime import datetime, timezone
5
5
  from typing import Any, Dict, Optional
6
6
  import json
7
7
 
8
8
 
9
9
  def isoformat(dt: datetime) -> str:
10
- return dt.astimezone(timezone.utc).isoformat()
10
+ if dt.tzinfo is None:
11
+ dt = dt.replace(tzinfo=timezone.utc)
12
+ dt = dt.astimezone(timezone.utc)
13
+ return dt.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
11
14
 
12
15
 
13
16
  @dataclass
@@ -16,40 +19,138 @@ class DataPayload:
16
19
  uom: Optional[str] = None
17
20
  time: Optional[str] = None
18
21
  dataGroup: Optional[str] = None
19
- createdAt: Optional[str] = None
20
- expiresAt: Optional[str] = None
22
+ intervalStart: Optional[str | int] = None
23
+ intervalEnd: Optional[str | int] = None
24
+ windowStart: Optional[str | int] = None
25
+ windowEnd: Optional[str | int] = None
26
+ eventId: Optional[str] = None
27
+ deleted: Optional[bool] = None
28
+ deletedAt: Optional[str | int] = None
29
+ lastSeen: Optional[str | int] = None
30
+ foreignEventKey: Optional[str] = None
21
31
 
22
32
 
23
33
  @dataclass
24
34
  class TablePayload:
25
- table: Any
35
+ time: str
36
+ columns: Any
26
37
  dataGroup: Optional[str] = None
27
- createdAt: Optional[str] = None
28
- expiresAt: Optional[str] = None
38
+ intervalStart: Optional[str | int] = None
39
+ intervalEnd: Optional[str | int] = None
40
+ windowStart: Optional[str | int] = None
41
+ windowEnd: Optional[str | int] = None
42
+ eventId: Optional[str] = None
43
+ deleted: Optional[bool] = None
44
+ deletedAt: Optional[str | int] = None
45
+ lastSeen: Optional[str | int] = None
46
+
47
+
48
+ def _value_type(value: Any) -> str:
49
+ if value is None:
50
+ return "null"
51
+ if isinstance(value, bool):
52
+ return "boolean"
53
+ if isinstance(value, (int, float)):
54
+ return "number"
55
+ if isinstance(value, str):
56
+ return "string"
57
+ return "object"
58
+
59
+
60
+ def _normalize_payload_fields(payload: Dict[str, Any], extra: Dict[str, Any]) -> None:
61
+ mapping = {
62
+ "data_group": "dataGroup",
63
+ "interval_start": "intervalStart",
64
+ "interval_end": "intervalEnd",
65
+ "window_start": "windowStart",
66
+ "window_end": "windowEnd",
67
+ "event_id": "eventId",
68
+ "deleted_at": "deletedAt",
69
+ "last_seen": "lastSeen",
70
+ "foreign_event_key": "foreignEventKey",
71
+ }
72
+ for key, value in extra.items():
73
+ target = mapping.get(key, key)
74
+ payload[target] = value
75
+
76
+
77
+ def _prune_none(payload: Dict[str, Any]) -> None:
78
+ for key in list(payload.keys()):
79
+ if payload[key] is None:
80
+ payload.pop(key, None)
29
81
 
30
82
 
31
83
  class UnsPacket:
32
- version: int = 1
84
+ version: str = "1.3.0"
33
85
 
34
86
  @staticmethod
35
87
  def data(
36
88
  value: Any,
37
89
  uom: Optional[str] = None,
38
- time: Optional[datetime] = None,
90
+ time: Optional[datetime | str] = None,
39
91
  data_group: Optional[str] = None,
92
+ created_at: Optional[datetime | str] = None,
93
+ expires_at: Optional[datetime | str] = None,
94
+ **extra: Any,
40
95
  ) -> Dict[str, Any]:
41
- payload = DataPayload(
42
- value=value,
43
- uom=uom,
44
- time=isoformat(time or datetime.utcnow()),
45
- dataGroup=data_group,
96
+ resolved_time = (
97
+ time if isinstance(time, str) else isoformat(time or datetime.now(timezone.utc))
46
98
  )
47
- return {"version": UnsPacket.version, "message": {"data": asdict(payload)}}
99
+ payload = {
100
+ "value": value,
101
+ "uom": uom,
102
+ "time": resolved_time,
103
+ "dataGroup": data_group,
104
+ "valueType": _value_type(value),
105
+ }
106
+ _normalize_payload_fields(payload, extra)
107
+ _prune_none(payload)
108
+ message: Dict[str, Any] = {"data": payload}
109
+ if created_at is not None:
110
+ message["createdAt"] = created_at if isinstance(created_at, str) else isoformat(created_at)
111
+ if expires_at is not None:
112
+ message["expiresAt"] = expires_at if isinstance(expires_at, str) else isoformat(expires_at)
113
+ return {"version": UnsPacket.version, "message": message}
48
114
 
49
115
  @staticmethod
50
- def table(table: Any, data_group: Optional[str] = None) -> Dict[str, Any]:
51
- payload = TablePayload(table=table, dataGroup=data_group)
52
- return {"version": UnsPacket.version, "message": {"table": asdict(payload)}}
116
+ def table(
117
+ table: Optional[Dict[str, Any]] = None,
118
+ *,
119
+ columns: Optional[Any] = None,
120
+ time: Optional[datetime | str] = None,
121
+ data_group: Optional[str] = None,
122
+ created_at: Optional[datetime | str] = None,
123
+ expires_at: Optional[datetime | str] = None,
124
+ **extra: Any,
125
+ ) -> Dict[str, Any]:
126
+ if table is None:
127
+ if columns is None:
128
+ raise ValueError("table() requires either table dict or columns list")
129
+ resolved_time = (
130
+ time if isinstance(time, str) else isoformat(time or datetime.now(timezone.utc))
131
+ )
132
+ payload = {
133
+ "time": resolved_time,
134
+ "columns": columns,
135
+ "dataGroup": data_group,
136
+ }
137
+ else:
138
+ payload = dict(table)
139
+ if "time" not in payload:
140
+ payload["time"] = (
141
+ time if isinstance(time, str) else isoformat(time or datetime.now(timezone.utc))
142
+ )
143
+ if data_group is not None and "dataGroup" not in payload:
144
+ payload["dataGroup"] = data_group
145
+
146
+ _normalize_payload_fields(payload, extra)
147
+ _prune_none(payload)
148
+ message: Dict[str, Any] = {"table": payload}
149
+ if created_at is not None:
150
+ message["createdAt"] = created_at if isinstance(created_at, str) else isoformat(created_at)
151
+ if expires_at is not None:
152
+ message["expiresAt"] = expires_at if isinstance(expires_at, str) else isoformat(expires_at)
153
+ return {"version": UnsPacket.version, "message": message}
53
154
 
54
155
  @staticmethod
55
156
  def to_json(packet: Dict[str, Any]) -> str:
@@ -61,3 +162,14 @@ class UnsPacket:
61
162
  return json.loads(packet_str)
62
163
  except Exception:
63
164
  return None
165
+
166
+ @staticmethod
167
+ def from_message(message: Dict[str, Any]) -> Dict[str, Any]:
168
+ msg = dict(message)
169
+ data = msg.get("data")
170
+ if isinstance(data, dict):
171
+ if "valueType" not in data and "value" in data:
172
+ data = dict(data)
173
+ data["valueType"] = _value_type(data.get("value"))
174
+ msg["data"] = data
175
+ return {"version": UnsPacket.version, "message": msg}