rtls-sdk 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtls_sdk/__init__.py +123 -0
- rtls_sdk/_auth.py +266 -0
- rtls_sdk/_client.py +419 -0
- rtls_sdk/_envelope.py +74 -0
- rtls_sdk/_http.py +145 -0
- rtls_sdk/_logging.py +143 -0
- rtls_sdk/_pagination.py +235 -0
- rtls_sdk/_query.py +114 -0
- rtls_sdk/_time.py +84 -0
- rtls_sdk/compounds/__init__.py +19 -0
- rtls_sdk/compounds/auth.py +100 -0
- rtls_sdk/compounds/context.py +159 -0
- rtls_sdk/compounds/groups.py +126 -0
- rtls_sdk/compounds/nodes.py +176 -0
- rtls_sdk/compounds/reports.py +339 -0
- rtls_sdk/compounds/system.py +43 -0
- rtls_sdk/compounds/tags.py +404 -0
- rtls_sdk/compounds/users.py +143 -0
- rtls_sdk/compounds/zones.py +203 -0
- rtls_sdk/errors.py +238 -0
- rtls_sdk/models/__init__.py +73 -0
- rtls_sdk/models/_base.py +46 -0
- rtls_sdk/models/alarm.py +23 -0
- rtls_sdk/models/anchor.py +25 -0
- rtls_sdk/models/area.py +28 -0
- rtls_sdk/models/association.py +48 -0
- rtls_sdk/models/bulk.py +80 -0
- rtls_sdk/models/company.py +32 -0
- rtls_sdk/models/csv_blob.py +40 -0
- rtls_sdk/models/floorplan.py +42 -0
- rtls_sdk/models/group.py +20 -0
- rtls_sdk/models/heatmap.py +37 -0
- rtls_sdk/models/import_result.py +42 -0
- rtls_sdk/models/node.py +28 -0
- rtls_sdk/models/notification.py +38 -0
- rtls_sdk/models/position.py +66 -0
- rtls_sdk/models/project.py +26 -0
- rtls_sdk/models/pws.py +38 -0
- rtls_sdk/models/report.py +34 -0
- rtls_sdk/models/session_context.py +81 -0
- rtls_sdk/models/site.py +31 -0
- rtls_sdk/models/subscriber.py +68 -0
- rtls_sdk/models/system.py +97 -0
- rtls_sdk/models/system_health.py +36 -0
- rtls_sdk/models/tag.py +48 -0
- rtls_sdk/models/tag_template.py +24 -0
- rtls_sdk/models/user.py +121 -0
- rtls_sdk/models/zone.py +27 -0
- rtls_sdk/models/zone_event.py +21 -0
- rtls_sdk/py.typed +0 -0
- rtls_sdk/resources/__init__.py +49 -0
- rtls_sdk/resources/_base.py +63 -0
- rtls_sdk/resources/alarms.py +108 -0
- rtls_sdk/resources/anchors.py +147 -0
- rtls_sdk/resources/areas.py +78 -0
- rtls_sdk/resources/associations.py +157 -0
- rtls_sdk/resources/auth.py +63 -0
- rtls_sdk/resources/companies.py +79 -0
- rtls_sdk/resources/context.py +50 -0
- rtls_sdk/resources/events.py +149 -0
- rtls_sdk/resources/floorplans.py +283 -0
- rtls_sdk/resources/groups.py +99 -0
- rtls_sdk/resources/logger.py +40 -0
- rtls_sdk/resources/messaging.py +51 -0
- rtls_sdk/resources/nodes.py +157 -0
- rtls_sdk/resources/notifications.py +55 -0
- rtls_sdk/resources/projects.py +67 -0
- rtls_sdk/resources/reports.py +180 -0
- rtls_sdk/resources/sites.py +115 -0
- rtls_sdk/resources/subscribers.py +110 -0
- rtls_sdk/resources/system.py +125 -0
- rtls_sdk/resources/tags.py +370 -0
- rtls_sdk/resources/users.py +275 -0
- rtls_sdk/resources/zones.py +199 -0
- rtls_sdk-0.2.0.dist-info/METADATA +141 -0
- rtls_sdk-0.2.0.dist-info/RECORD +78 -0
- rtls_sdk-0.2.0.dist-info/WHEEL +4 -0
- rtls_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
rtls_sdk/_logging.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Named logger and secret redaction for SDK HTTP traffic.
|
|
2
|
+
|
|
3
|
+
Redaction is mandatory and has no opt-out flag. If the user wants to log
|
|
4
|
+
raw payloads they can install their own ``httpx`` event hooks on a separate
|
|
5
|
+
client; the SDK's logger never emits credentials at any level.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("rtls_sdk")
|
|
16
|
+
|
|
17
|
+
_REDACTED = "***"
|
|
18
|
+
|
|
19
|
+
_REDACT_HEADER_NAMES: frozenset[str] = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"x-user-token",
|
|
22
|
+
"x-user-email",
|
|
23
|
+
"x-user-refresh-token",
|
|
24
|
+
"authorization",
|
|
25
|
+
"cookie",
|
|
26
|
+
"set-cookie",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_REDACT_BODY_KEYS: frozenset[str] = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"password",
|
|
33
|
+
"token",
|
|
34
|
+
"refresh_token",
|
|
35
|
+
"access_token",
|
|
36
|
+
"client_secret",
|
|
37
|
+
"api_key",
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Strings longer than this in a logged body are replaced with a
|
|
42
|
+
# ``<truncated N bytes>`` placeholder. Set so a base64-encoded image
|
|
43
|
+
# payload (typically tens of KB to MB) doesn't pollute DEBUG logs and
|
|
44
|
+
# obscure the structurally interesting fields. 1024 chars covers any
|
|
45
|
+
# legitimate user-facing string (names, descriptions, URLs) with room
|
|
46
|
+
# to spare.
|
|
47
|
+
_MAX_LOGGED_STRING_LEN: int = 1024
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_headers(headers: httpx.Headers | dict[str, str]) -> dict[str, str]:
|
|
51
|
+
"""Return a copy of ``headers`` with sensitive values masked."""
|
|
52
|
+
out: dict[str, str] = {}
|
|
53
|
+
for key, value in headers.items():
|
|
54
|
+
if key.lower() in _REDACT_HEADER_NAMES:
|
|
55
|
+
out[key] = _REDACTED
|
|
56
|
+
else:
|
|
57
|
+
out[key] = value
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def redact_body(body: Any) -> Any:
|
|
62
|
+
"""Recursively mask sensitive values in a parsed JSON body.
|
|
63
|
+
|
|
64
|
+
Three transformations applied to every node:
|
|
65
|
+
|
|
66
|
+
1. Keys in :data:`_REDACT_BODY_KEYS` (case-insensitive) have their
|
|
67
|
+
values replaced with ``"***"``.
|
|
68
|
+
2. String values longer than :data:`_MAX_LOGGED_STRING_LEN` are
|
|
69
|
+
replaced with ``"<truncated N bytes>"``. This keeps base64
|
|
70
|
+
image payloads and other large blobs out of DEBUG logs.
|
|
71
|
+
3. Containers are recursed into; lists / dicts return a new
|
|
72
|
+
structure (the input is never mutated).
|
|
73
|
+
"""
|
|
74
|
+
if isinstance(body, dict):
|
|
75
|
+
return {
|
|
76
|
+
key: (_REDACTED if key.lower() in _REDACT_BODY_KEYS else redact_body(value))
|
|
77
|
+
for key, value in body.items()
|
|
78
|
+
}
|
|
79
|
+
if isinstance(body, list):
|
|
80
|
+
return [redact_body(item) for item in body]
|
|
81
|
+
if isinstance(body, str) and len(body) > _MAX_LOGGED_STRING_LEN:
|
|
82
|
+
return f"<truncated {len(body)} bytes>"
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def log_request(request: httpx.Request) -> None:
|
|
87
|
+
"""httpx request event hook — log method + URL + redacted headers + body at DEBUG.
|
|
88
|
+
|
|
89
|
+
The request body is parsed as JSON when ``content-type`` is
|
|
90
|
+
``application/json`` and run through :func:`redact_body`, which both
|
|
91
|
+
masks secret keys (passwords, tokens) and truncates strings longer
|
|
92
|
+
than 1024 chars (image payloads, etc.). Non-JSON bodies log as
|
|
93
|
+
``<binary N bytes>`` placeholders.
|
|
94
|
+
"""
|
|
95
|
+
if not logger.isEnabledFor(logging.DEBUG):
|
|
96
|
+
return
|
|
97
|
+
body: Any = None
|
|
98
|
+
content_type = request.headers.get("content-type", "")
|
|
99
|
+
if content_type.startswith("application/json") and request.content:
|
|
100
|
+
try:
|
|
101
|
+
import json
|
|
102
|
+
|
|
103
|
+
body = redact_body(json.loads(request.content))
|
|
104
|
+
except (ValueError, UnicodeDecodeError):
|
|
105
|
+
body = f"<unparseable {len(request.content)} bytes>"
|
|
106
|
+
elif request.content:
|
|
107
|
+
body = f"<binary {len(request.content)} bytes>"
|
|
108
|
+
logger.debug(
|
|
109
|
+
"request %s %s headers=%s body=%s",
|
|
110
|
+
request.method,
|
|
111
|
+
request.url,
|
|
112
|
+
redact_headers(request.headers),
|
|
113
|
+
body,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def log_response(response: httpx.Response) -> None:
|
|
118
|
+
"""httpx response event hook — log status at DEBUG; body if available."""
|
|
119
|
+
if not logger.isEnabledFor(logging.DEBUG):
|
|
120
|
+
return
|
|
121
|
+
body: Any = None
|
|
122
|
+
if response.headers.get("content-type", "").startswith("application/json"):
|
|
123
|
+
try:
|
|
124
|
+
response.read()
|
|
125
|
+
body = response.json()
|
|
126
|
+
except Exception:
|
|
127
|
+
body = None
|
|
128
|
+
logger.debug(
|
|
129
|
+
"response %s %s status=%s body=%s",
|
|
130
|
+
response.request.method,
|
|
131
|
+
response.request.url,
|
|
132
|
+
response.status_code,
|
|
133
|
+
redact_body(body),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = [
|
|
138
|
+
"log_request",
|
|
139
|
+
"log_response",
|
|
140
|
+
"logger",
|
|
141
|
+
"redact_body",
|
|
142
|
+
"redact_headers",
|
|
143
|
+
]
|
rtls_sdk/_pagination.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Pagination paginators — three server styles, one SDK shape.
|
|
2
|
+
|
|
3
|
+
The RTLS server uses three pagination conventions across endpoints
|
|
4
|
+
(RESEARCH §2):
|
|
5
|
+
|
|
6
|
+
- **Style 1 — header cursor**: response header
|
|
7
|
+
``content-next-positions-page`` carries the next ``page`` value.
|
|
8
|
+
Used by ``positions_history``.
|
|
9
|
+
- **Style 2 — body-included nextPage URL**: response body has
|
|
10
|
+
``nextPage`` (full URL) and ``totalCount``. Used by
|
|
11
|
+
``external_events`` / PWS events.
|
|
12
|
+
- **Style 3 — bare page+limit**: caller supplies ``page`` /
|
|
13
|
+
``limit``; no metadata in the response. Continue until an empty page.
|
|
14
|
+
|
|
15
|
+
Each style has its own ``Paginator`` subclass; resource methods
|
|
16
|
+
construct the right one and expose a single iterator surface to
|
|
17
|
+
callers. The :class:`Page` value type is identical across styles.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Callable, Iterator
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Page(Generic[T]):
|
|
35
|
+
"""One page of a paginated result.
|
|
36
|
+
|
|
37
|
+
Attributes
|
|
38
|
+
----------
|
|
39
|
+
items
|
|
40
|
+
Parsed items on this page.
|
|
41
|
+
page_number
|
|
42
|
+
1-indexed.
|
|
43
|
+
total_count
|
|
44
|
+
Total across all pages, if the server reported it (Style 2 only).
|
|
45
|
+
has_next
|
|
46
|
+
``True`` if at least one more page is expected.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
items: list[T] = field(default_factory=list)
|
|
50
|
+
page_number: int = 1
|
|
51
|
+
total_count: int | None = None
|
|
52
|
+
has_next: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# A fetcher takes (url, params) and returns the raw httpx.Response.
|
|
56
|
+
# Resource methods supply ``client._http.get``-shaped callable.
|
|
57
|
+
Fetcher = Callable[..., "httpx.Response"]
|
|
58
|
+
# Parse a server payload into typed items.
|
|
59
|
+
ItemParser = Callable[[Any], list[T]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Paginator(Generic[T]):
|
|
63
|
+
"""Common surface: ``pages()`` and ``__iter__()``."""
|
|
64
|
+
|
|
65
|
+
def pages(self) -> Iterator[Page[T]]:
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def __iter__(self) -> Iterator[T]:
|
|
69
|
+
for page in self.pages():
|
|
70
|
+
yield from page.items
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class HeaderCursorPaginator(_Paginator[T]):
|
|
74
|
+
"""Style 1: server returns next-page cursor in a response header.
|
|
75
|
+
|
|
76
|
+
The cursor value is whatever the server emitted (often a sequence
|
|
77
|
+
number); we feed it back as ``params["page"]`` on the next call.
|
|
78
|
+
A cursor that's literally absent (header missing) or the string
|
|
79
|
+
``"null"`` ends pagination.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
fetch: Fetcher,
|
|
86
|
+
path: str,
|
|
87
|
+
params: dict[str, Any] | list[tuple[str, str]],
|
|
88
|
+
parse_items: ItemParser[T],
|
|
89
|
+
header_name: str = "content-next-positions-page",
|
|
90
|
+
) -> None:
|
|
91
|
+
self._fetch = fetch
|
|
92
|
+
self._path = path
|
|
93
|
+
# Coerce to a mutable dict so we can update ``page`` between calls.
|
|
94
|
+
if isinstance(params, dict):
|
|
95
|
+
self._params: dict[str, Any] = dict(params)
|
|
96
|
+
else:
|
|
97
|
+
self._params = dict(params)
|
|
98
|
+
self._parse_items = parse_items
|
|
99
|
+
self._header_name = header_name
|
|
100
|
+
|
|
101
|
+
def pages(self) -> Iterator[Page[T]]:
|
|
102
|
+
page_number = 0
|
|
103
|
+
while True:
|
|
104
|
+
page_number += 1
|
|
105
|
+
response = self._fetch(self._path, params=self._params)
|
|
106
|
+
items = self._parse_items(response.json())
|
|
107
|
+
cursor = response.headers.get(self._header_name)
|
|
108
|
+
has_next = bool(cursor) and cursor != "null"
|
|
109
|
+
yield Page(
|
|
110
|
+
items=items,
|
|
111
|
+
page_number=page_number,
|
|
112
|
+
total_count=None,
|
|
113
|
+
has_next=has_next,
|
|
114
|
+
)
|
|
115
|
+
if not has_next:
|
|
116
|
+
return
|
|
117
|
+
self._params["page"] = cursor
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class BodyUrlPaginator(_Paginator[T]):
|
|
121
|
+
"""Style 2: server returns ``nextPage`` URL in the response body.
|
|
122
|
+
|
|
123
|
+
The URL is fed verbatim to the fetcher; the SDK does not append
|
|
124
|
+
query params to it (the server's URL already carries the right
|
|
125
|
+
query). Termination is ``nextPage`` being ``null`` / absent.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
fetch: Fetcher,
|
|
132
|
+
path: str,
|
|
133
|
+
params: dict[str, Any] | list[tuple[str, str]],
|
|
134
|
+
parse_items: ItemParser[T],
|
|
135
|
+
list_key: str,
|
|
136
|
+
next_page_key: str = "nextPage",
|
|
137
|
+
total_key: str = "totalCount",
|
|
138
|
+
) -> None:
|
|
139
|
+
self._fetch = fetch
|
|
140
|
+
self._initial_path = path
|
|
141
|
+
self._initial_params = params
|
|
142
|
+
self._parse_items = parse_items
|
|
143
|
+
self._list_key = list_key
|
|
144
|
+
self._next_page_key = next_page_key
|
|
145
|
+
self._total_key = total_key
|
|
146
|
+
|
|
147
|
+
def pages(self) -> Iterator[Page[T]]:
|
|
148
|
+
page_number = 0
|
|
149
|
+
path: str | None = self._initial_path
|
|
150
|
+
params: Any = self._initial_params
|
|
151
|
+
|
|
152
|
+
while path is not None:
|
|
153
|
+
page_number += 1
|
|
154
|
+
response = self._fetch(path, params=params)
|
|
155
|
+
payload = response.json()
|
|
156
|
+
list_value = payload.get(self._list_key, []) if isinstance(payload, dict) else []
|
|
157
|
+
items = self._parse_items(list_value)
|
|
158
|
+
total = payload.get(self._total_key) if isinstance(payload, dict) else None
|
|
159
|
+
next_page = payload.get(self._next_page_key) if isinstance(payload, dict) else None
|
|
160
|
+
has_next = bool(next_page)
|
|
161
|
+
yield Page(
|
|
162
|
+
items=items,
|
|
163
|
+
page_number=page_number,
|
|
164
|
+
total_count=total if isinstance(total, int) else None,
|
|
165
|
+
has_next=has_next,
|
|
166
|
+
)
|
|
167
|
+
if has_next and isinstance(next_page, str):
|
|
168
|
+
path = next_page
|
|
169
|
+
# The next-page URL carries its own query string.
|
|
170
|
+
params = None
|
|
171
|
+
else:
|
|
172
|
+
path = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PageLimitPaginator(_Paginator[T]):
|
|
176
|
+
"""Style 3: caller drives via ``page`` + ``limit`` query params.
|
|
177
|
+
|
|
178
|
+
No metadata in the response — the SDK heuristically continues
|
|
179
|
+
until a page comes back with fewer items than ``page_size``, OR
|
|
180
|
+
until the server explicitly stops (an empty page).
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
fetch: Fetcher,
|
|
187
|
+
path: str,
|
|
188
|
+
params: dict[str, Any] | list[tuple[str, str]],
|
|
189
|
+
parse_items: ItemParser[T],
|
|
190
|
+
page_size: int = 1000,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._fetch = fetch
|
|
193
|
+
self._path = path
|
|
194
|
+
# Coerce params to a list so we can append page/limit as tuples
|
|
195
|
+
# without overwriting existing ones (the alarms-style endpoints
|
|
196
|
+
# use Rails array params that need list-of-tuples).
|
|
197
|
+
if isinstance(params, dict):
|
|
198
|
+
self._params: list[tuple[str, str]] = [(k, str(v)) for k, v in params.items()]
|
|
199
|
+
else:
|
|
200
|
+
self._params = [(k, str(v)) for k, v in params]
|
|
201
|
+
self._parse_items = parse_items
|
|
202
|
+
self._page_size = page_size
|
|
203
|
+
|
|
204
|
+
def pages(self) -> Iterator[Page[T]]:
|
|
205
|
+
page_number = 0
|
|
206
|
+
while True:
|
|
207
|
+
page_number += 1
|
|
208
|
+
paged_params = [
|
|
209
|
+
*self._params,
|
|
210
|
+
("page", str(page_number)),
|
|
211
|
+
("limit", str(self._page_size)),
|
|
212
|
+
]
|
|
213
|
+
response = self._fetch(self._path, params=paged_params)
|
|
214
|
+
items = self._parse_items(response.json())
|
|
215
|
+
# Stop when the server returns fewer than the requested
|
|
216
|
+
# page size (or zero rows).
|
|
217
|
+
has_next = len(items) >= self._page_size
|
|
218
|
+
yield Page(
|
|
219
|
+
items=items,
|
|
220
|
+
page_number=page_number,
|
|
221
|
+
total_count=None,
|
|
222
|
+
has_next=has_next,
|
|
223
|
+
)
|
|
224
|
+
if not has_next:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
__all__ = [
|
|
229
|
+
"BodyUrlPaginator",
|
|
230
|
+
"Fetcher",
|
|
231
|
+
"HeaderCursorPaginator",
|
|
232
|
+
"ItemParser",
|
|
233
|
+
"Page",
|
|
234
|
+
"PageLimitPaginator",
|
|
235
|
+
]
|
rtls_sdk/_query.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Query-string, identifier, and payload helpers.
|
|
2
|
+
|
|
3
|
+
The RTLS server expects Rails-style array parameters with a literal
|
|
4
|
+
``[]`` suffix on the parameter name — e.g. ``alarm_type_name[]=a&alarm_type_name[]=b``.
|
|
5
|
+
``httpx``'s default ``params=`` serializer does not add the suffix, so we
|
|
6
|
+
build the parameter pairs explicitly as a list of tuples.
|
|
7
|
+
|
|
8
|
+
This module also hosts:
|
|
9
|
+
|
|
10
|
+
- :func:`normalize_mac` — used by node / association write paths.
|
|
11
|
+
- :func:`encode_image_payload` — used by floorplan upload (M10) to
|
|
12
|
+
build the ``image: {original_filename, file: <base64>}`` sub-object
|
|
13
|
+
the server's Joi schema requires.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
# A loose pre-filter: anything that contains exactly 12 hex digits, with
|
|
23
|
+
# optional separators between byte pairs. We strip separators and revalidate
|
|
24
|
+
# strictly before emitting the canonical form.
|
|
25
|
+
_MAC_SEPARATOR_RE = re.compile(r"[:\-]")
|
|
26
|
+
_HEX_12_RE = re.compile(r"^[0-9a-fA-F]{12}$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_mac(mac: str) -> str:
|
|
30
|
+
"""Return ``mac`` in canonical bare 12-hex **lowercase** form.
|
|
31
|
+
|
|
32
|
+
The RTLS server's validation regex is ``/^[a-fA-F0-9]{12}$/`` — no
|
|
33
|
+
separators allowed; either case accepted on input. The server stores
|
|
34
|
+
and returns MACs in lowercase (observed live), so the SDK
|
|
35
|
+
normalizes to lowercase to keep ``sent_mac == returned_mac``
|
|
36
|
+
round-trips clean.
|
|
37
|
+
|
|
38
|
+
Six convenient input shapes accepted; raises :class:`ValueError`
|
|
39
|
+
on malformed input. The error message includes the offending value
|
|
40
|
+
so a caller's failed HTTP-shaped batch shows which input was wrong.
|
|
41
|
+
|
|
42
|
+
Accepted inputs:
|
|
43
|
+
|
|
44
|
+
- ``aa:bb:cc:dd:ee:ff``
|
|
45
|
+
- ``AA:BB:CC:DD:EE:FF``
|
|
46
|
+
- ``aa-bb-cc-dd-ee-ff``
|
|
47
|
+
- ``AA-BB-CC-DD-EE-FF``
|
|
48
|
+
- ``aabbccddeeff``
|
|
49
|
+
- ``AABBCCDDEEFF``
|
|
50
|
+
|
|
51
|
+
Examples
|
|
52
|
+
--------
|
|
53
|
+
>>> normalize_mac("AA-BB-CC-DD-EE-FF")
|
|
54
|
+
'aabbccddeeff'
|
|
55
|
+
>>> normalize_mac("aabbccddeeff")
|
|
56
|
+
'aabbccddeeff'
|
|
57
|
+
"""
|
|
58
|
+
if not isinstance(mac, str) or not mac:
|
|
59
|
+
raise ValueError(f"mac_address must be a non-empty string; got {mac!r}")
|
|
60
|
+
stripped = _MAC_SEPARATOR_RE.sub("", mac)
|
|
61
|
+
if not _HEX_12_RE.match(stripped):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"mac_address must be 12 hex digits (with optional ':' or '-' separators); got {mac!r}"
|
|
64
|
+
)
|
|
65
|
+
return stripped.lower()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def encode_image_payload(image_bytes: bytes, original_filename: str) -> dict[str, Any]:
|
|
69
|
+
"""Build the ``image: {original_filename, file: <base64>}`` sub-object.
|
|
70
|
+
|
|
71
|
+
The RTLS server's ``createFloorplan`` Joi schema requires both keys
|
|
72
|
+
inside the ``image`` object — see M10's live probe. The ``file``
|
|
73
|
+
value is a standard RFC 4648 §4 base64 string (NOT URL-safe).
|
|
74
|
+
|
|
75
|
+
Examples
|
|
76
|
+
--------
|
|
77
|
+
>>> encode_image_payload(b"hello", "x.png")
|
|
78
|
+
{'original_filename': 'x.png', 'file': 'aGVsbG8='}
|
|
79
|
+
"""
|
|
80
|
+
if not isinstance(image_bytes, bytes):
|
|
81
|
+
raise TypeError(f"image_bytes must be bytes, got {type(image_bytes).__name__}")
|
|
82
|
+
if not isinstance(original_filename, str) or not original_filename:
|
|
83
|
+
raise ValueError("original_filename must be a non-empty string")
|
|
84
|
+
return {
|
|
85
|
+
"original_filename": original_filename,
|
|
86
|
+
"file": base64.b64encode(image_bytes).decode("ascii"),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pack_array_param(name: str, values: list[str]) -> list[tuple[str, str]]:
|
|
91
|
+
"""Build a list of ``(key, value)`` tuples for a Rails-style array param.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
name
|
|
96
|
+
The parameter name *without* trailing ``[]``. The function adds it.
|
|
97
|
+
values
|
|
98
|
+
One value per emitted query parameter.
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
list[tuple[str, str]]
|
|
103
|
+
Empty if ``values`` is empty.
|
|
104
|
+
|
|
105
|
+
Examples
|
|
106
|
+
--------
|
|
107
|
+
>>> pack_array_param("alarm_type_name", ["zone_violation", "buffer_violation"])
|
|
108
|
+
[('alarm_type_name[]', 'zone_violation'), ('alarm_type_name[]', 'buffer_violation')]
|
|
109
|
+
"""
|
|
110
|
+
key = f"{name}[]"
|
|
111
|
+
return [(key, value) for value in values]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["encode_image_payload", "normalize_mac", "pack_array_param"]
|
rtls_sdk/_time.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Timestamp conversion helpers.
|
|
2
|
+
|
|
3
|
+
The RTLS API mixes ISO-8601 strings (reports), epoch milliseconds
|
|
4
|
+
(alarms, zone events, position history), and — for one alarm endpoint —
|
|
5
|
+
``Date.getTime()`` integers (RESEARCH §2). The SDK normalizes by accepting
|
|
6
|
+
``datetime`` everywhere and converting to whatever the target endpoint
|
|
7
|
+
expects.
|
|
8
|
+
|
|
9
|
+
All inbound ``datetime`` values must be **timezone-aware**. Naive
|
|
10
|
+
datetimes raise ``ValueError`` with a clear message; the SDK refuses to
|
|
11
|
+
guess UTC vs. local time.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ensure_aware(dt: datetime, *, arg_name: str = "dt") -> datetime:
|
|
20
|
+
"""Reject naive datetimes early.
|
|
21
|
+
|
|
22
|
+
``dt.tzinfo`` is ``None`` for naive datetimes. We reject rather than
|
|
23
|
+
assume UTC because silent assumption is the kind of bug that causes
|
|
24
|
+
off-by-hours data joins.
|
|
25
|
+
"""
|
|
26
|
+
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"{arg_name} must be a timezone-aware datetime — "
|
|
29
|
+
f"the SDK does not assume UTC. Use datetime.now(timezone.utc) "
|
|
30
|
+
f"or attach a tzinfo before passing it in."
|
|
31
|
+
)
|
|
32
|
+
return dt
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_epoch_ms(dt: datetime, *, arg_name: str = "dt") -> int:
|
|
36
|
+
"""Convert a tz-aware datetime to integer epoch milliseconds.
|
|
37
|
+
|
|
38
|
+
Used for alarms, zone events, position history, and other endpoints
|
|
39
|
+
that accept ``from=`` / ``to=`` as millisecond integers.
|
|
40
|
+
|
|
41
|
+
Raises
|
|
42
|
+
------
|
|
43
|
+
ValueError
|
|
44
|
+
If ``dt`` is naive (no tzinfo).
|
|
45
|
+
"""
|
|
46
|
+
dt = _ensure_aware(dt, arg_name=arg_name)
|
|
47
|
+
return int(dt.timestamp() * 1000)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def to_epoch_seconds(dt: datetime, *, arg_name: str = "dt") -> int:
|
|
51
|
+
"""Convert a tz-aware datetime to integer epoch seconds.
|
|
52
|
+
|
|
53
|
+
The ``/track_assoc?start=&end=`` endpoint uses seconds, not
|
|
54
|
+
milliseconds (RESEARCH §4 "Load trackables with active associations").
|
|
55
|
+
"""
|
|
56
|
+
dt = _ensure_aware(dt, arg_name=arg_name)
|
|
57
|
+
return int(dt.timestamp())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def to_iso8601(dt: datetime, *, arg_name: str = "dt") -> str:
|
|
61
|
+
"""Convert a tz-aware datetime to an ISO-8601 string with offset.
|
|
62
|
+
|
|
63
|
+
Used for the reports and external-events endpoints.
|
|
64
|
+
"""
|
|
65
|
+
dt = _ensure_aware(dt, arg_name=arg_name)
|
|
66
|
+
return dt.isoformat()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def from_epoch_ms(value: int | float) -> datetime:
|
|
70
|
+
"""Build a tz-aware UTC datetime from epoch milliseconds.
|
|
71
|
+
|
|
72
|
+
Used by models that surface server-side timestamps. The SDK chooses
|
|
73
|
+
UTC over local time so consumers can convert downstream without
|
|
74
|
+
ambiguity.
|
|
75
|
+
"""
|
|
76
|
+
return datetime.fromtimestamp(value / 1000.0, tz=timezone.utc)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"from_epoch_ms",
|
|
81
|
+
"to_epoch_ms",
|
|
82
|
+
"to_epoch_seconds",
|
|
83
|
+
"to_iso8601",
|
|
84
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Compound multi-call workflows.
|
|
2
|
+
|
|
3
|
+
Each module in this package implements the multi-call sequences that
|
|
4
|
+
DESIGN §4 promised the SDK would collapse into single methods. Resource
|
|
5
|
+
sub-clients in ``rtls_sdk.resources`` delegate here.
|
|
6
|
+
|
|
7
|
+
Step-name conventions for :class:`PartialFailureError`
|
|
8
|
+
------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
When a compound fails mid-sequence the exception carries a
|
|
11
|
+
``failed_step`` string. These strings are part of the SDK's public
|
|
12
|
+
contract — callers may key recovery logic off them. They never change
|
|
13
|
+
once shipped.
|
|
14
|
+
|
|
15
|
+
The strings used by each compound are documented in the docstring of
|
|
16
|
+
its public entry point on the resource sub-client.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Compound auth workflows.
|
|
2
|
+
|
|
3
|
+
Currently ships just ``change_password``. Future compounds (SSO login,
|
|
4
|
+
session-bootstrap variants) may land here.
|
|
5
|
+
|
|
6
|
+
Step names for ``change_password``: ``validate_old``, ``validate_new``,
|
|
7
|
+
``change_password``, ``reauthenticate``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from ..errors import AuthenticationError, PartialFailureError, RtlsError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .._client import RtlsClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("rtls_sdk.compounds.auth")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def change_password(client: RtlsClient, old_password: str, new_password: str) -> None:
|
|
25
|
+
"""Change the current user's password and re-authenticate.
|
|
26
|
+
|
|
27
|
+
Sequence:
|
|
28
|
+
|
|
29
|
+
1. ``validate_old``: confirm the supplied old password matches.
|
|
30
|
+
2. ``validate_new``: confirm the new password passes server policy.
|
|
31
|
+
3. ``change_password``: ``PUT /users/changePassword``.
|
|
32
|
+
4. ``reauthenticate``: clear the current token, swap to the new
|
|
33
|
+
password, force a fresh login.
|
|
34
|
+
|
|
35
|
+
If step 4 fails (server accepted the change but the new login
|
|
36
|
+
fails for some reason — race, network blip), the SDK raises
|
|
37
|
+
:class:`AuthenticationError` with explicit guidance: the password
|
|
38
|
+
is changed server-side but the in-memory state can't continue;
|
|
39
|
+
reconstruct the client with the new password.
|
|
40
|
+
"""
|
|
41
|
+
self_user = client.users.me()
|
|
42
|
+
self_uid = self_user.uid
|
|
43
|
+
if not self_uid:
|
|
44
|
+
raise RtlsError("change_password: current user has no uid — cannot proceed")
|
|
45
|
+
|
|
46
|
+
completed: list[str] = []
|
|
47
|
+
|
|
48
|
+
# Step 1: validate old password.
|
|
49
|
+
try:
|
|
50
|
+
client.users.validate(password=old_password, uid=self_uid, isOld=True)
|
|
51
|
+
except RtlsError as exc:
|
|
52
|
+
raise PartialFailureError(
|
|
53
|
+
f"auth.change_password: validate_old failed: {exc}",
|
|
54
|
+
completed_steps=completed,
|
|
55
|
+
failed_step="validate_old",
|
|
56
|
+
cause=exc,
|
|
57
|
+
) from exc
|
|
58
|
+
completed.append("validate_old")
|
|
59
|
+
|
|
60
|
+
# Step 2: validate new password.
|
|
61
|
+
try:
|
|
62
|
+
client.users.validate(password=new_password, uid=self_uid, isOld=False)
|
|
63
|
+
except RtlsError as exc:
|
|
64
|
+
raise PartialFailureError(
|
|
65
|
+
f"auth.change_password: validate_new failed: {exc}",
|
|
66
|
+
completed_steps=completed,
|
|
67
|
+
failed_step="validate_new",
|
|
68
|
+
cause=exc,
|
|
69
|
+
) from exc
|
|
70
|
+
completed.append("validate_new")
|
|
71
|
+
|
|
72
|
+
# Step 3: change password.
|
|
73
|
+
try:
|
|
74
|
+
client.users.change_password_raw(
|
|
75
|
+
self_uid, old_password=old_password, new_password=new_password
|
|
76
|
+
)
|
|
77
|
+
except RtlsError as exc:
|
|
78
|
+
raise PartialFailureError(
|
|
79
|
+
f"auth.change_password: change_password failed: {exc}",
|
|
80
|
+
completed_steps=completed,
|
|
81
|
+
failed_step="change_password",
|
|
82
|
+
cause=exc,
|
|
83
|
+
) from exc
|
|
84
|
+
completed.append("change_password")
|
|
85
|
+
|
|
86
|
+
# Step 4: re-authenticate with the new password.
|
|
87
|
+
try:
|
|
88
|
+
client._auth_state.set_password(new_password)
|
|
89
|
+
client._auth_state.login_if_needed()
|
|
90
|
+
except (RtlsError, RuntimeError) as exc:
|
|
91
|
+
raise AuthenticationError(
|
|
92
|
+
"auth.change_password: the server accepted the password change "
|
|
93
|
+
"but re-authentication failed. The in-memory client state is "
|
|
94
|
+
"now stale — reconstruct the client with the new password and "
|
|
95
|
+
"your existing email.",
|
|
96
|
+
status_code=getattr(exc, "status_code", None),
|
|
97
|
+
) from exc
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
__all__ = ["change_password"]
|