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.
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/CHANGELOG.md +79 -1
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/PKG-INFO +2 -1
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/prompts/README.md +6 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/pyproject.toml +2 -1
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/__init__.py +35 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_client.py +4 -0
- rtls_sdk-0.3.0/src/rtls_sdk/_ws.py +390 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/errors.py +33 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/__init__.py +76 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/_base.py +53 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/alarm.py +34 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/notify.py +29 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/ota.py +23 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/param_p.py +25 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/position.py +41 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/sensor.py +23 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/user_msg.py +28 -0
- rtls_sdk-0.3.0/src/rtls_sdk/models/ws/zone_event.py +30 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/__init__.py +4 -0
- rtls_sdk-0.3.0/src/rtls_sdk/resources/user_messages.py +76 -0
- rtls_sdk-0.3.0/src/rtls_sdk/resources/ws.py +133 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/.gitignore +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/LICENSE +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/README.md +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_auth.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_envelope.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_http.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_logging.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_pagination.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_query.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/_time.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/__init__.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/auth.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/context.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/groups.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/nodes.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/reports.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/system.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/tags.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/users.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/compounds/zones.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/__init__.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/_base.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/alarm.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/anchor.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/area.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/association.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/bulk.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/company.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/csv_blob.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/floorplan.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/group.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/heatmap.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/import_result.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/node.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/notification.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/position.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/project.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/pws.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/report.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/session_context.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/site.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/subscriber.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/system.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/system_health.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/tag.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/tag_template.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/user.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/zone.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/models/zone_event.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/py.typed +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/_base.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/alarms.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/anchors.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/areas.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/associations.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/auth.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/companies.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/context.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/events.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/floorplans.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/groups.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/logger.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/messaging.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/nodes.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/notifications.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/projects.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/reports.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/sites.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/subscribers.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/system.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/tags.py +0 -0
- {rtls_sdk-0.2.0 → rtls_sdk-0.3.0}/src/rtls_sdk/resources/users.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
]
|