rtls-sdk 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (94) hide show
  1. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/CHANGELOG.md +79 -1
  2. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/PKG-INFO +2 -1
  3. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/prompts/README.md +6 -0
  4. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/pyproject.toml +2 -1
  5. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/__init__.py +35 -0
  6. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_client.py +4 -0
  7. rtls_sdk-0.3.0/src/rtls_sdk/_ws.py +390 -0
  8. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/errors.py +33 -0
  9. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/__init__.py +76 -0
  10. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/_base.py +53 -0
  11. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/alarm.py +34 -0
  12. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/notify.py +29 -0
  13. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/ota.py +23 -0
  14. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/param_p.py +25 -0
  15. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/position.py +41 -0
  16. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/sensor.py +23 -0
  17. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/user_msg.py +28 -0
  18. rtls_sdk-0.3.0/src/rtls_sdk/models/ws/zone_event.py +30 -0
  19. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/__init__.py +4 -0
  20. rtls_sdk-0.3.0/src/rtls_sdk/resources/user_messages.py +76 -0
  21. rtls_sdk-0.3.0/src/rtls_sdk/resources/ws.py +133 -0
  22. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/.gitignore +0 -0
  23. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/LICENSE +0 -0
  24. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/README.md +0 -0
  25. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_auth.py +0 -0
  26. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_envelope.py +0 -0
  27. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_http.py +0 -0
  28. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_logging.py +0 -0
  29. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_pagination.py +0 -0
  30. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_query.py +0 -0
  31. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_time.py +0 -0
  32. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/__init__.py +0 -0
  33. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/auth.py +0 -0
  34. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/context.py +0 -0
  35. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/groups.py +0 -0
  36. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/nodes.py +0 -0
  37. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/reports.py +0 -0
  38. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/system.py +0 -0
  39. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/tags.py +0 -0
  40. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/users.py +0 -0
  41. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/zones.py +0 -0
  42. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/__init__.py +0 -0
  43. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/_base.py +0 -0
  44. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/alarm.py +0 -0
  45. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/anchor.py +0 -0
  46. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/area.py +0 -0
  47. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/association.py +0 -0
  48. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/bulk.py +0 -0
  49. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/company.py +0 -0
  50. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/csv_blob.py +0 -0
  51. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/floorplan.py +0 -0
  52. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/group.py +0 -0
  53. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/heatmap.py +0 -0
  54. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/import_result.py +0 -0
  55. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/node.py +0 -0
  56. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/notification.py +0 -0
  57. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/position.py +0 -0
  58. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/project.py +0 -0
  59. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/pws.py +0 -0
  60. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/report.py +0 -0
  61. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/session_context.py +0 -0
  62. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/site.py +0 -0
  63. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/subscriber.py +0 -0
  64. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/system.py +0 -0
  65. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/system_health.py +0 -0
  66. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/tag.py +0 -0
  67. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/tag_template.py +0 -0
  68. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/user.py +0 -0
  69. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/zone.py +0 -0
  70. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/zone_event.py +0 -0
  71. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/py.typed +0 -0
  72. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/_base.py +0 -0
  73. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/alarms.py +0 -0
  74. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/anchors.py +0 -0
  75. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/areas.py +0 -0
  76. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/associations.py +0 -0
  77. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/auth.py +0 -0
  78. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/companies.py +0 -0
  79. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/context.py +0 -0
  80. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/events.py +0 -0
  81. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/floorplans.py +0 -0
  82. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/groups.py +0 -0
  83. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/logger.py +0 -0
  84. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/messaging.py +0 -0
  85. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/nodes.py +0 -0
  86. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/notifications.py +0 -0
  87. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/projects.py +0 -0
  88. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/reports.py +0 -0
  89. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/sites.py +0 -0
  90. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/subscribers.py +0 -0
  91. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/system.py +0 -0
  92. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/tags.py +0 -0
  93. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/users.py +0 -0
  94. {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/zones.py +0 -0
@@ -7,7 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.2.0] — 2026-05-12
10
+ ## [0.3.0] — 2026-05-13
11
+
12
+ ### Documentation
13
+
14
+ - New `entities/streaming.md` documenting `client.ws`, the eight
15
+ typed channel models, reconnects, heartbeats, backpressure, and a
16
+ REST → WS round-trip recipe. Reference pages (`resources.md`,
17
+ `models.md`, `errors.md`) now list the WS surface. Catalog and
18
+ quickstart link to the new page.
19
+
20
+ ### Added
21
+
22
+ #### Typed WS channel models (M18)
23
+
24
+ - `rtls_sdk.models.ws` — new sub-package with one pydantic model per
25
+ decoded channel: `PositionMessage` (pos), `NotifyMessage` (notify),
26
+ `AlarmMessage` (alarm), `ZoneEventMessage` (zone_event),
27
+ `SensorMessage` (sensor), `OtaMessage` (ota), `ParamPMessage`
28
+ (param_p), `UserMsgMessage` (user_msg). All eight inherit from
29
+ `WsChannelEnvelope` and carry the full wire dict on `.raw` for
30
+ forward-compat field access.
31
+ - `client.ws.subscribe(...)` now yields typed messages instead of the
32
+ M17 untyped envelope; iteration patterns like
33
+ `match msg: case PositionMessage(x=x, y=y): ...` work directly. The
34
+ yield type narrowed from `WsChannelMessage` to `WsChannelEnvelope`.
35
+ - Unknown channels and decode failures fall back to
36
+ `WsChannelMessage` (now a pydantic model, was a frozen dataclass) so
37
+ the iterator never dies on a single bad frame. Validation errors
38
+ log at WARNING with the channel name.
39
+ - New `include_heartbeats: bool = False` kwarg on
40
+ `client.ws.subscribe(...)`. By default, `notify` frames with
41
+ `action == "HB"` are dropped at the reader; opt-in callers receive
42
+ them as `NotifyMessage(action="HB")`. Heartbeat drops do not count
43
+ toward `session.messages_dropped` — that counter remains
44
+ backpressure-only.
45
+
46
+ ### Changed
47
+
48
+ - `WsChannelMessage` moved from `rtls_sdk._ws` to
49
+ `rtls_sdk.models.ws`; the import path under the top-level package
50
+ (`from rtls_sdk import WsChannelMessage`) is unchanged.
51
+
52
+ #### WebSocket subscription scaffold (M17)
53
+
54
+ - `client.ws` — new `WsAPI` sub-client wrapping the JSON-RPC 2.0
55
+ subscriptions-manager. `ws.list_channels()` returns the server's
56
+ advertised channel inventory in a one-shot dial (no auth required).
57
+ `ws.subscribe(channels, *, project_uid=None, reconnect=True,
58
+ queue_max=1024)` opens a long-lived session that is both a context
59
+ manager and a blocking iterator of `WsChannelMessage` envelopes.
60
+ Naming is transport-specific on purpose — future streaming
61
+ protocols (MQTT, Kafka) would land as siblings (`client.mqtt`,
62
+ `client.kafka`) rather than crowd a generic `client.stream`
63
+ namespace.
64
+ - `WsChannelMessage(channel, raw)` — untyped pass-through for any
65
+ channel; typed channel models land in M18.
66
+ - Reader runs on a daemon thread behind a bounded queue. Backpressure
67
+ drops the oldest message and bumps `session.messages_dropped`.
68
+ Auto-reconnect with capped exponential backoff
69
+ `(1, 2, 4, 8, 15, 30, 30, 30)` seconds on unclean disconnects;
70
+ re-subscribes the current channel set. Auth failure during reconnect
71
+ surfaces as `WsAuthError` from the iterator and stops further
72
+ attempts.
73
+ - New error classes `WsError`, `WsAuthError`, and `WsProtocolError`
74
+ (all subclasses of `RtlsError`).
75
+ - New dependency: `websockets >= 12.0`.
76
+
77
+ #### User messages REST (M16)
78
+
79
+ - `client.user_messages.send(tag_uid, hex)` — new `UserMessagesAPI`
80
+ sub-client; sends a hex payload to a tag's badge over the WIN
81
+ gateway. Wire endpoint: `POST /api/v2/user_msg/`. Independent of
82
+ `client.messaging` (which targets the inbox endpoint at
83
+ `/api/v2/messaging`); the two surfaces stay separate because the
84
+ endpoints serve different features on the wire (UDP frame vs. inbox
85
+ row) and share no storage. Client-side validation rejects malformed
86
+ hex before any network I/O.
87
+
88
+ ## [0.2.0] — 2026-05-13
11
89
 
12
90
  ### Added
13
91
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rtls-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Python SDK for the RTLS REST API
5
5
  Project-URL: Homepage, https://github.com/rpplabs/rtls-sdk
6
6
  Project-URL: Documentation, https://github.com/rpplabs/rtls-sdk/tree/master/docs
@@ -21,6 +21,7 @@ Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.10
22
22
  Requires-Dist: httpx>=0.27
23
23
  Requires-Dist: pydantic>=2.5
24
+ Requires-Dist: websockets>=12.0
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: mypy>=1.8; extra == 'dev'
26
27
  Requires-Dist: pytest-cov>=4.1; extra == 'dev'
@@ -17,6 +17,12 @@ These prompts implement the design in `/Users/yzhbankov/rtls/rtls-sdk/DESIGN.md`
17
17
  | 13 | `13-m11-entity-docs.md` | M11 | Docs-only: reorganize user documentation around **entities**. New `docs/entities/` tree (one page per entity + catalog index) replaces the workflow-oriented guides. Answers "what can I do with X?" on one page. |
18
18
  | 14 | `14-m12-schema-audit-mac-fix.md` | M12 | Schema-correctness audit. Fix three real bugs caught by reading the server's Joi schemas directly: MAC format is bare 12-hex (not colon-separated); `tags.create` does not accept `mac_address` (server silently strips); add live MAC round-trip test as a regression gate. |
19
19
  | 15 | `15-m13-final-audit.md` | M13 | **Check-only.** Full ship-readiness audit across code quality, documentation site, internal consistency, wire-shape correctness, deep doc-quality analysis (with persona-driven proposals), and release artifacts. Produces `AUDIT.md` at the repo root with a verdict (ready / not ready), itemised blockers, repo-wide markdown link validation, and a prioritised list of documentation improvement proposals. Does not modify code or docs — a separate milestone applies any accepted fixes. |
20
+ | 16 | `16-m14-e2e-live-test.md` | M14 | Full-stack live E2E test (`tests/live/test_m14_full_stack.py`). Builds the entity tree top-down, lists, updates each kind, deletes bottom-up. Fix-test-retry discipline — surfaces SDK bugs and fixes them in-flight. |
21
+ | 17 | `17-m15-user-msg-websocket-research.md` | M15 | **Research-only.** Investigates the server-side `user_msg` REST endpoint and the JSON-RPC WebSocket protocol (subscriptions-manager). Produces `prompts/17-m15-research.md` with wire shapes, channel inventory, discrepancies, and concrete SDK extension signatures. |
22
+ | 18 | `18-m16-user-msg-rest.md` | M16 | `messaging.send_to_tag(tag_uid, hex)` — POST `/api/v2/user_msg/` sends a hex payload to a tag's badge over the WIN gateway. Unit + integration + live test. |
23
+ | 19 | `19-m17-ws-scaffold.md` | M17 | WebSocket sub-client `client.stream`: connect, list channels, subscribe / unsubscribe, blocking-iterator over `RawChannelMessage`, heartbeat passthrough, reconnect with backoff. Dependency: `websockets`. |
24
+ | 20 | `20-m18-ws-typed-models.md` | M18 | Typed channel models (`PositionMessage`, `AlarmMessage`, `NotifyMessage`, `ZoneEventMessage`, `SensorMessage`, `OtaMessage`, `ParamPMessage`, `UserMsgMessage`) + discriminated-union decode dispatch + `include_heartbeats` flag (default off). |
25
+ | 21 | `21-m19-ws-live-gate.md` | M19 | End-to-end live gate: REST `send_to_tag` → WS `user_msg` notify round-trip. Five-consecutive-pass stability requirement. |
20
26
 
21
27
  ## Inputs each prompt reads
22
28
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "rtls-sdk"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Python SDK for the RTLS REST API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -24,6 +24,7 @@ classifiers = [
24
24
  dependencies = [
25
25
  "httpx>=0.27",
26
26
  "pydantic>=2.5",
27
+ "websockets>=12.0",
27
28
  ]
28
29
 
29
30
  [project.optional-dependencies]
@@ -28,6 +28,9 @@ from .errors import (
28
28
  RtlsError,
29
29
  ServerError,
30
30
  ValidationError,
31
+ WsAuthError,
32
+ WsError,
33
+ WsProtocolError,
31
34
  )
32
35
  from .models import (
33
36
  Alarm,
@@ -66,10 +69,39 @@ from .models import (
66
69
  Zone,
67
70
  ZoneEvent,
68
71
  )
72
+ from .models.ws import (
73
+ AlarmMessage,
74
+ NotifyMessage,
75
+ OtaMessage,
76
+ ParamPMessage,
77
+ PositionMessage,
78
+ SensorMessage,
79
+ UserMsgMessage,
80
+ WsChannelEnvelope,
81
+ WsChannelMessage,
82
+ ZoneEventMessage,
83
+ )
84
+ from .resources import UserMessagesAPI, WsAPI
85
+ from .resources.ws import WsSession
69
86
 
70
87
  __all__ = [ # noqa: RUF022 — grouped logically, not alphabetically
71
88
  # Client
72
89
  "RtlsClient",
90
+ # Resource sub-clients
91
+ "UserMessagesAPI",
92
+ "WsAPI",
93
+ # WebSocket types
94
+ "AlarmMessage",
95
+ "NotifyMessage",
96
+ "OtaMessage",
97
+ "ParamPMessage",
98
+ "PositionMessage",
99
+ "SensorMessage",
100
+ "UserMsgMessage",
101
+ "WsChannelEnvelope",
102
+ "WsChannelMessage",
103
+ "WsSession",
104
+ "ZoneEventMessage",
73
105
  # Errors
74
106
  "AuthenticationError",
75
107
  "Conflict",
@@ -84,6 +116,9 @@ __all__ = [ # noqa: RUF022 — grouped logically, not alphabetically
84
116
  "RtlsError",
85
117
  "ServerError",
86
118
  "ValidationError",
119
+ "WsAuthError",
120
+ "WsError",
121
+ "WsProtocolError",
87
122
  # Models
88
123
  "Alarm",
89
124
  "Anchor",
@@ -39,7 +39,9 @@ from .resources import (
39
39
  SubscribersAPI,
40
40
  SystemAPI,
41
41
  TagsAPI,
42
+ UserMessagesAPI,
42
43
  UsersAPI,
44
+ WsAPI,
43
45
  ZonesAPI,
44
46
  )
45
47
 
@@ -178,6 +180,8 @@ class RtlsClient:
178
180
  self.system = SystemAPI(self)
179
181
  self.auth = AuthAPI(self)
180
182
  self.messaging = MessagingAPI(self)
183
+ self.user_messages = UserMessagesAPI(self)
184
+ self.ws = WsAPI(self)
181
185
  self.logger = LoggerAPI(self)
182
186
  self.context = ContextAPI(self)
183
187
 
@@ -0,0 +1,390 @@
1
+ """Internal WebSocket session machinery for :class:`WsAPI`.
2
+
3
+ The public surface lives in :mod:`rtls_sdk.resources.ws`; this module
4
+ hosts the JSON-RPC 2.0 client, reader-thread loop, and bounded queue
5
+ iterator. M18 dispatches ``params.subscription`` to typed pydantic
6
+ models in :mod:`rtls_sdk.models.ws`; the fallback
7
+ :class:`WsChannelMessage` is re-exported from that sub-package and
8
+ remains the public name for unknown / un-modelled channels. Sibling
9
+ streaming transports (MQTT, Kafka) would live in their own
10
+ ``_mqtt.py`` / ``_kafka.py`` modules with their own session types —
11
+ this module is WS-specific.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import json
18
+ import logging
19
+ import queue
20
+ import threading
21
+ from collections.abc import Iterator
22
+ from types import TracebackType
23
+ from typing import Any
24
+
25
+ import websockets.exceptions
26
+ from websockets.sync.client import ClientConnection
27
+ from websockets.sync.client import connect as ws_connect
28
+
29
+ from ._auth import _AuthState
30
+ from .errors import WsAuthError, WsError
31
+ from .models.ws import WsChannelEnvelope, WsChannelMessage, decode_notify
32
+
33
+ _log = logging.getLogger("rtls_sdk.ws")
34
+
35
+
36
+ def _close_code(exc: websockets.exceptions.ConnectionClosed) -> int | None:
37
+ """Close code from the received close frame; ``None`` if none received."""
38
+ rcvd = exc.rcvd
39
+ return None if rcvd is None else rcvd.code
40
+
41
+
42
+ _SENTINEL_CLOSED: object = object()
43
+ _SENTINEL_AUTH_FAIL: object = object()
44
+ _SENTINEL_RECONNECT_EXHAUSTED: object = object()
45
+
46
+ # Reconnect backoff. Once exhausted the session surfaces ``WsError``.
47
+ _BACKOFF_SECONDS: tuple[float, ...] = (1, 2, 4, 8, 15, 30, 30, 30)
48
+
49
+ # Close codes the server may emit on auth rejection: 1008 policy
50
+ # violation + 4001 / 4003 application-range codes.
51
+ _AUTH_CLOSE_CODES: frozenset[int] = frozenset({1008, 4001, 4003})
52
+
53
+
54
+ class _WsSession:
55
+ """JSON-RPC WS subscription session backing ``WsAPI.subscribe``.
56
+
57
+ The reader runs on a daemon thread and pushes messages into a
58
+ bounded queue; iteration pops them off. Backpressure drops the
59
+ *oldest* message, bumps :attr:`messages_dropped`, and logs once
60
+ per 100 drops.
61
+ """
62
+
63
+ channels: tuple[str, ...]
64
+ project_uid: str
65
+ messages_dropped: int
66
+
67
+ def __init__(
68
+ self,
69
+ *,
70
+ ws_url: str,
71
+ channels: list[str],
72
+ auth_state: _AuthState,
73
+ project_uid: str,
74
+ reconnect: bool,
75
+ queue_max: int,
76
+ include_heartbeats: bool = False,
77
+ ) -> None:
78
+ self._ws_url = ws_url
79
+ self._channels: list[str] = list(channels)
80
+ self._auth_state = auth_state
81
+ self.project_uid = project_uid
82
+ self._reconnect = reconnect
83
+ self._include_heartbeats = include_heartbeats
84
+ self._queue: queue.Queue[Any] = queue.Queue(maxsize=queue_max)
85
+ self._lock = threading.RLock()
86
+ self._shutdown = threading.Event()
87
+ self._ws: ClientConnection | None = None
88
+ self._reader_thread: threading.Thread | None = None
89
+ self._id_lock = threading.Lock()
90
+ self._next_id_value = 1
91
+ self.messages_dropped = 0
92
+
93
+ @property
94
+ def channels_tuple(self) -> tuple[str, ...]:
95
+ with self._lock:
96
+ return tuple(self._channels)
97
+
98
+ # -- lifecycle --------------------------------------------------------
99
+
100
+ def _start(self) -> None:
101
+ """Dial, subscribe synchronously, spin up the reader thread."""
102
+ ws = self._dial_and_subscribe(self._channels)
103
+ with self._lock:
104
+ self._ws = ws
105
+ thread = threading.Thread(target=self._reader_loop, name="rtls-sdk-ws-reader", daemon=True)
106
+ self._reader_thread = thread
107
+ thread.start()
108
+
109
+ def __enter__(self) -> _WsSession:
110
+ return self
111
+
112
+ def __exit__(
113
+ self,
114
+ exc_type: type[BaseException] | None,
115
+ exc: BaseException | None,
116
+ tb: TracebackType | None,
117
+ ) -> None:
118
+ self.close()
119
+
120
+ def close(self) -> None:
121
+ """Best-effort unsubscribe, close the socket, wake the iterator."""
122
+ if self._shutdown.is_set():
123
+ return
124
+ self._shutdown.set()
125
+ with self._lock:
126
+ ws = self._ws
127
+ channels = list(self._channels)
128
+ if ws is not None:
129
+ if channels:
130
+ with contextlib.suppress(Exception):
131
+ ws.send(json.dumps(self._make_payload("delete", channels)))
132
+ with contextlib.suppress(Exception):
133
+ ws.close()
134
+ with contextlib.suppress(queue.Full):
135
+ self._queue.put_nowait(_SENTINEL_CLOSED)
136
+
137
+ # -- iteration --------------------------------------------------------
138
+
139
+ def __iter__(self) -> Iterator[WsChannelEnvelope]:
140
+ while True:
141
+ item = self._queue.get()
142
+ if item is _SENTINEL_CLOSED:
143
+ return
144
+ if item is _SENTINEL_AUTH_FAIL:
145
+ raise WsAuthError("WS server rejected the credentials on subscribe")
146
+ if item is _SENTINEL_RECONNECT_EXHAUSTED:
147
+ raise WsError("reconnect attempts exhausted; giving up")
148
+ assert isinstance(item, WsChannelEnvelope)
149
+ yield item
150
+
151
+ # -- channel-set mutation --------------------------------------------
152
+
153
+ def add_channels(self, channels: list[str]) -> None:
154
+ """Send ``add`` for new channels and merge them into the session set."""
155
+ with self._lock:
156
+ ws = self._ws
157
+ if ws is None:
158
+ raise WsError("session is not connected")
159
+ new = [c for c in channels if c not in self._channels]
160
+ if not new:
161
+ return
162
+ ws.send(json.dumps(self._make_payload("add", new)))
163
+ self._channels.extend(new)
164
+
165
+ def remove_channels(self, channels: list[str]) -> None:
166
+ """Send ``delete`` for the given channels and drop them from the set."""
167
+ with self._lock:
168
+ ws = self._ws
169
+ if ws is None:
170
+ raise WsError("session is not connected")
171
+ to_remove = [c for c in channels if c in self._channels]
172
+ if not to_remove:
173
+ return
174
+ ws.send(json.dumps(self._make_payload("delete", to_remove)))
175
+ self._channels = [c for c in self._channels if c not in to_remove]
176
+
177
+ # -- internals --------------------------------------------------------
178
+
179
+ def _next_id(self) -> int:
180
+ with self._id_lock:
181
+ value = self._next_id_value
182
+ self._next_id_value += 1
183
+ return value
184
+
185
+ def _make_payload(self, method: str, channels: list[str]) -> dict[str, Any]:
186
+ """Build an ``add`` or ``delete`` JSON-RPC request body."""
187
+ return {
188
+ "jsonrpc": "2.0",
189
+ "method": method,
190
+ "params": {
191
+ "data": channels,
192
+ "entity": "subscriptions",
193
+ "userEmail": self._auth_state.email or "",
194
+ "authToken": self._auth_state.token or "",
195
+ "project_uid": self.project_uid,
196
+ },
197
+ "id": self._next_id(),
198
+ }
199
+
200
+ def _dial_and_subscribe(self, channels: list[str]) -> ClientConnection:
201
+ """Open the WS, send ``add``, wait for the ack, return the socket."""
202
+ ws = ws_connect(self._ws_url)
203
+ try:
204
+ payload = self._make_payload("add", channels)
205
+ sub_id = payload["id"]
206
+ ws.send(json.dumps(payload))
207
+ self._await_subscribe_ack(ws, sub_id)
208
+ except BaseException:
209
+ with contextlib.suppress(Exception):
210
+ ws.close()
211
+ raise
212
+ return ws
213
+
214
+ def _await_subscribe_ack(self, ws: ClientConnection, sub_id: int) -> None:
215
+ """Read frames until the ``add`` ack arrives; raise on auth fail.
216
+
217
+ Buffers unrelated ``notify`` frames onto the queue so they aren't
218
+ lost. The first frame matching ``id == sub_id`` is the ack;
219
+ ``result: [<channels>]`` is success, anything else is auth fail.
220
+ """
221
+ while True:
222
+ try:
223
+ frame = ws.recv()
224
+ except websockets.exceptions.ConnectionClosed as exc:
225
+ code = _close_code(exc)
226
+ # No close frame OR auth-flavoured code: treat as auth
227
+ # rejection per the M16 probe.
228
+ if code is None or code in _AUTH_CLOSE_CODES or code == 1005:
229
+ raise WsAuthError(f"WS closed with code {code} before subscribe ack") from exc
230
+ raise WsError(f"WS closed with code {code} before subscribe ack") from exc
231
+
232
+ if isinstance(frame, bytes):
233
+ continue
234
+
235
+ stripped = frame.strip()
236
+ # Service-code shape on auth failure (JSON-stringified Error).
237
+ if stripped == "{}":
238
+ raise WsAuthError("WS server returned an empty error envelope on subscribe")
239
+
240
+ try:
241
+ data = json.loads(frame)
242
+ except json.JSONDecodeError:
243
+ _log.warning("dropping non-JSON WS frame during subscribe")
244
+ continue
245
+ if not isinstance(data, dict):
246
+ continue
247
+
248
+ # HTTP-style auth-error envelope (live server primary branch).
249
+ if self._looks_like_auth_envelope(data):
250
+ raise WsAuthError(f"WS server returned auth-error envelope: {data!r}")
251
+ if data.get("id") == sub_id and "error" in data:
252
+ raise WsAuthError(f"WS server returned JSON-RPC error: {data['error']!r}")
253
+ if data.get("id") == sub_id:
254
+ result = data.get("result")
255
+ if isinstance(result, list) and len(result) > 0:
256
+ return
257
+ # ``result: []`` is the QA-test auth-fail shape.
258
+ raise WsAuthError("WS server returned empty result on subscribe")
259
+ # Anything else: an early notify. Buffer it.
260
+ self._handle_notify_frame(data)
261
+
262
+ @staticmethod
263
+ def _looks_like_auth_envelope(data: dict[str, Any]) -> bool:
264
+ """Match the live server's HTTP-style auth-error frame."""
265
+ if data.get("httpCode") == 401:
266
+ return True
267
+ err_code = data.get("errorCode")
268
+ return isinstance(err_code, str) and "AUTH" in err_code.upper()
269
+
270
+ def _handle_notify_frame(self, data: dict[str, Any]) -> None:
271
+ """Route a parsed JSON frame to the iterator if it's a ``notify``."""
272
+ if data.get("method") != "notify":
273
+ return
274
+ params = data.get("params")
275
+ if not isinstance(params, dict):
276
+ return
277
+ channel = params.get("subscription")
278
+ body = params.get("data")
279
+ if not isinstance(channel, str) or not isinstance(body, dict):
280
+ return
281
+ # Heartbeat filter (M18): the ``notify`` channel may carry frames
282
+ # with ``action == "HB"``; suppress by default and let opt-in
283
+ # callers see them. Filter at the raw level rather than after
284
+ # decode so an HB frame without ``project_uid`` (server emits HB
285
+ # without project scoping) doesn't fall through to
286
+ # ``WsChannelMessage`` and then need a second-pass type check.
287
+ if not self._include_heartbeats and channel == "notify" and body.get("action") == "HB":
288
+ return
289
+ self._push(decode_notify(channel, body))
290
+
291
+ def _push(self, msg: WsChannelEnvelope) -> None:
292
+ """Enqueue a message, dropping the oldest on overflow."""
293
+ try:
294
+ self._queue.put_nowait(msg)
295
+ return
296
+ except queue.Full:
297
+ pass
298
+ with contextlib.suppress(queue.Empty):
299
+ self._queue.get_nowait()
300
+ with contextlib.suppress(queue.Full):
301
+ self._queue.put_nowait(msg)
302
+ self.messages_dropped += 1
303
+ if self.messages_dropped % 100 == 1:
304
+ _log.warning(
305
+ "stream queue full; dropped %d messages total",
306
+ self.messages_dropped,
307
+ )
308
+
309
+ def _push_sentinel(self, sentinel: object) -> None:
310
+ """Push a sentinel, evicting the oldest message if necessary."""
311
+ try:
312
+ self._queue.put_nowait(sentinel)
313
+ return
314
+ except queue.Full:
315
+ pass
316
+ with contextlib.suppress(queue.Empty):
317
+ self._queue.get_nowait()
318
+ with contextlib.suppress(queue.Full):
319
+ self._queue.put_nowait(sentinel)
320
+
321
+ def _reader_loop(self) -> None:
322
+ """Daemon reader: pull frames, route notifies, reconnect on close."""
323
+ while not self._shutdown.is_set():
324
+ with self._lock:
325
+ ws = self._ws
326
+ if ws is None:
327
+ self._push_sentinel(_SENTINEL_CLOSED)
328
+ return
329
+ try:
330
+ frame = ws.recv()
331
+ except websockets.exceptions.ConnectionClosed as exc:
332
+ if self._shutdown.is_set():
333
+ self._push_sentinel(_SENTINEL_CLOSED)
334
+ return
335
+ code = _close_code(exc)
336
+ if code in _AUTH_CLOSE_CODES:
337
+ self._push_sentinel(_SENTINEL_AUTH_FAIL)
338
+ return
339
+ if not self._reconnect:
340
+ _log.info("WS closed (code=%s); reconnect disabled", code)
341
+ self._push_sentinel(_SENTINEL_CLOSED)
342
+ return
343
+ if not self._try_reconnect():
344
+ return
345
+ continue
346
+ except Exception:
347
+ _log.exception("unexpected error in reader loop")
348
+ self._push_sentinel(_SENTINEL_CLOSED)
349
+ return
350
+
351
+ if isinstance(frame, bytes):
352
+ continue
353
+ try:
354
+ data = json.loads(frame)
355
+ except json.JSONDecodeError:
356
+ _log.warning("dropping non-JSON WS frame")
357
+ continue
358
+ if isinstance(data, dict):
359
+ self._handle_notify_frame(data)
360
+
361
+ def _try_reconnect(self) -> bool:
362
+ """Re-dial with exponential backoff and re-subscribe.
363
+
364
+ Returns True if reconnected. Returns False on shutdown, auth
365
+ failure, or exhaustion — the appropriate sentinel is already
366
+ on the queue in each terminal case.
367
+ """
368
+ for attempt, delay in enumerate(_BACKOFF_SECONDS):
369
+ if self._shutdown.wait(delay):
370
+ self._push_sentinel(_SENTINEL_CLOSED)
371
+ return False
372
+ with self._lock:
373
+ channels = list(self._channels)
374
+ try:
375
+ ws = self._dial_and_subscribe(channels)
376
+ except WsAuthError:
377
+ _log.info("auth rejected on reconnect; giving up")
378
+ self._push_sentinel(_SENTINEL_AUTH_FAIL)
379
+ return False
380
+ except Exception as exc:
381
+ _log.warning("reconnect attempt %d failed: %s", attempt + 1, exc)
382
+ continue
383
+ with self._lock:
384
+ self._ws = ws
385
+ return True
386
+ self._push_sentinel(_SENTINEL_RECONNECT_EXHAUSTED)
387
+ return False
388
+
389
+
390
+ __all__ = ["WsChannelEnvelope", "WsChannelMessage", "_WsSession"]
@@ -155,6 +155,36 @@ class PartialFailureError(RtlsError):
155
155
  self.partial_result: dict[str, Any] = partial_result or {}
156
156
 
157
157
 
158
+ class WsError(RtlsError):
159
+ """Base class for WebSocket surface errors.
160
+
161
+ Raised from :class:`WsAPI` and the iterator returned by
162
+ :meth:`WsAPI.subscribe`. Subclasses pin specific failure modes;
163
+ catch ``WsError`` to handle any WS-side failure uniformly. Future
164
+ streaming protocols (MQTT, Kafka) would carry their own error
165
+ hierarchies; this base intentionally does not generalize beyond WS.
166
+ """
167
+
168
+
169
+ class WsAuthError(WsError):
170
+ """The WS server rejected the credentials on subscribe.
171
+
172
+ Surfaced from the session iterator the first time the server's
173
+ response (or close behavior) indicates the token / email pair was
174
+ not accepted on ``add``. Reconnection stops on auth failure.
175
+ """
176
+
177
+
178
+ class WsProtocolError(WsError):
179
+ """The WS server returned a malformed or unexpected JSON-RPC frame.
180
+
181
+ Reserved for fatal frame-shape violations. The session's reader
182
+ tolerates an isolated bad frame (logged + skipped); a
183
+ ``WsProtocolError`` indicates something the session cannot
184
+ recover from.
185
+ """
186
+
187
+
158
188
  class ReportError(RtlsError):
159
189
  """Base for report-specific failures."""
160
190
 
@@ -233,6 +263,9 @@ __all__ = [
233
263
  "RtlsError",
234
264
  "ServerError",
235
265
  "ValidationError",
266
+ "WsAuthError",
267
+ "WsError",
268
+ "WsProtocolError",
236
269
  "exception_for_status",
237
270
  "parse_error_envelope",
238
271
  ]