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
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Compound report workflows — heatmap polling, PWS aggregation, CSV chunking.
|
|
2
|
+
|
|
3
|
+
Step-name conventions (stable):
|
|
4
|
+
|
|
5
|
+
- ``heatmap``: ``create_report``, ``poll``, ``build_image_url``. The
|
|
6
|
+
polling loop doesn't emit individual step names per poll; the whole
|
|
7
|
+
loop is one logical step.
|
|
8
|
+
|
|
9
|
+
The CSV downloaders don't have step names (single linear sequence;
|
|
10
|
+
failures surface as the underlying ``RtlsError``).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import IO, TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from .._query import pack_array_param
|
|
22
|
+
from .._time import to_epoch_ms
|
|
23
|
+
from ..errors import ReportFailed, ReportTimeout
|
|
24
|
+
from ..models import CsvBlob, Heatmap, PwsEvent, PwsReport
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from .._client import RtlsClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("rtls_sdk.compounds.reports")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---- heatmap ------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_heatmap(
|
|
37
|
+
client: RtlsClient,
|
|
38
|
+
*,
|
|
39
|
+
start: datetime,
|
|
40
|
+
end: datetime,
|
|
41
|
+
tag_uids: list[str],
|
|
42
|
+
names: list[str] | None = None,
|
|
43
|
+
poll_interval: float = 2.0,
|
|
44
|
+
timeout: float | None = None,
|
|
45
|
+
) -> Heatmap:
|
|
46
|
+
"""Create a heatmap report and poll until it completes.
|
|
47
|
+
|
|
48
|
+
Uses the live ``/api/v2/obsolete/report`` paths (RESEARCH
|
|
49
|
+
Discrepancy #5 — the documented ``/api/reports`` path isn't what
|
|
50
|
+
the frontend uses).
|
|
51
|
+
|
|
52
|
+
- Default ``timeout=None`` is **unbounded** (confirmed by maintainer,
|
|
53
|
+
DESIGN §9 R3). Pass a finite value to bound the wait.
|
|
54
|
+
- ``poll_interval`` defaults to 2.0s, matching the JS reference
|
|
55
|
+
client.
|
|
56
|
+
- ``ReportTimeout.report_uid`` exposes the in-flight uid so callers
|
|
57
|
+
can resume polling later via :meth:`ReportsAPI.get`.
|
|
58
|
+
"""
|
|
59
|
+
body: dict[str, Any] = {
|
|
60
|
+
"type": "heatmap_report",
|
|
61
|
+
"start": to_epoch_ms(start, arg_name="start"),
|
|
62
|
+
"end": to_epoch_ms(end, arg_name="end"),
|
|
63
|
+
"uids": list(tag_uids),
|
|
64
|
+
}
|
|
65
|
+
if names is not None:
|
|
66
|
+
body["names"] = list(names)
|
|
67
|
+
|
|
68
|
+
response = client._request("POST", "/api/v2/obsolete/report", json=body)
|
|
69
|
+
payload = response.json()
|
|
70
|
+
uid = _extract_report_uid(payload)
|
|
71
|
+
if uid is None:
|
|
72
|
+
raise ReportFailed(
|
|
73
|
+
"heatmap: server response did not include a report uid",
|
|
74
|
+
response_body=payload,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
78
|
+
while True:
|
|
79
|
+
poll_response = client._request("GET", f"/api/v2/obsolete/report/{uid}")
|
|
80
|
+
poll_payload = poll_response.json()
|
|
81
|
+
|
|
82
|
+
if isinstance(poll_payload, dict):
|
|
83
|
+
error_field = poll_payload.get("error")
|
|
84
|
+
if isinstance(error_field, str) and error_field:
|
|
85
|
+
raise ReportFailed(
|
|
86
|
+
f"heatmap: server reported error: {error_field}",
|
|
87
|
+
response_body=poll_payload,
|
|
88
|
+
)
|
|
89
|
+
output = poll_payload.get("output")
|
|
90
|
+
if output is not None:
|
|
91
|
+
# Completed — build the image URL.
|
|
92
|
+
return _build_heatmap_model(client, uid, poll_payload)
|
|
93
|
+
|
|
94
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
95
|
+
raise ReportTimeout(
|
|
96
|
+
f"heatmap: timed out after {timeout}s — server still working",
|
|
97
|
+
report_uid=uid,
|
|
98
|
+
)
|
|
99
|
+
# Suppress noisy per-poll logging — the loop can run for minutes.
|
|
100
|
+
time.sleep(poll_interval)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _extract_report_uid(payload: Any) -> str | None:
|
|
104
|
+
"""Pick out the report uid from a heatmap create/poll response."""
|
|
105
|
+
if isinstance(payload, dict):
|
|
106
|
+
if isinstance(payload.get("uid"), str):
|
|
107
|
+
return str(payload["uid"])
|
|
108
|
+
data = payload.get("data")
|
|
109
|
+
if isinstance(data, dict) and isinstance(data.get("uid"), str):
|
|
110
|
+
return str(data["uid"])
|
|
111
|
+
report = payload.get("report")
|
|
112
|
+
if isinstance(report, dict) and isinstance(report.get("uid"), str):
|
|
113
|
+
return str(report["uid"])
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _build_heatmap_model(client: RtlsClient, uid: str, payload: dict[str, Any]) -> Heatmap:
|
|
118
|
+
"""Construct the user-facing :class:`Heatmap` from the poll payload."""
|
|
119
|
+
base = str(client._http.base_url).rstrip("/")
|
|
120
|
+
# The output may be a dict (e.g. {url, search}) or a string.
|
|
121
|
+
output = payload.get("output")
|
|
122
|
+
output_metadata: dict[str, Any] = {}
|
|
123
|
+
search = ""
|
|
124
|
+
if isinstance(output, dict):
|
|
125
|
+
output_metadata = dict(output)
|
|
126
|
+
raw_search = output.get("search") or output.get("query")
|
|
127
|
+
if isinstance(raw_search, str):
|
|
128
|
+
search = raw_search if raw_search.startswith("?") else f"?{raw_search}"
|
|
129
|
+
explicit_url = output.get("url")
|
|
130
|
+
if isinstance(explicit_url, str) and explicit_url:
|
|
131
|
+
image_url = explicit_url
|
|
132
|
+
else:
|
|
133
|
+
image_url = f"{base}/api/v2/heatmap/{uid}{search}"
|
|
134
|
+
elif isinstance(output, str):
|
|
135
|
+
image_url = output
|
|
136
|
+
else:
|
|
137
|
+
image_url = f"{base}/api/v2/heatmap/{uid}"
|
|
138
|
+
|
|
139
|
+
return Heatmap.from_wire(
|
|
140
|
+
{
|
|
141
|
+
"uid": uid,
|
|
142
|
+
"image_url": image_url,
|
|
143
|
+
"name": payload.get("name"),
|
|
144
|
+
"input": payload.get("input"),
|
|
145
|
+
"output_metadata": output_metadata,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---- pws aggregate ------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def generate_pws_report(
|
|
154
|
+
client: RtlsClient,
|
|
155
|
+
*,
|
|
156
|
+
trackable_uid: str | list[str],
|
|
157
|
+
start: datetime,
|
|
158
|
+
end: datetime,
|
|
159
|
+
timezone: str | None = None,
|
|
160
|
+
) -> PwsReport:
|
|
161
|
+
"""Aggregate all PWS-event pages into a :class:`PwsReport`.
|
|
162
|
+
|
|
163
|
+
Holds the entire result set in memory. For large windows prefer
|
|
164
|
+
:meth:`EventsAPI.iter_pws` (lazy).
|
|
165
|
+
"""
|
|
166
|
+
paginator = client.events._build_pws_paginator(trackable_uid, start, end, 1000)
|
|
167
|
+
events: list[PwsEvent] = []
|
|
168
|
+
total: int | None = None
|
|
169
|
+
for page in paginator.pages():
|
|
170
|
+
events.extend(page.items)
|
|
171
|
+
if page.total_count is not None:
|
|
172
|
+
total = page.total_count
|
|
173
|
+
return PwsReport(events=events, total_count=total, timezone=timezone)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ---- CSV downloads ------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
_NEXT_PAGE_HEADER = "content-next-page"
|
|
180
|
+
_FILENAME_HEADER = "response-filename"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def download_zone_activity_csv(
|
|
184
|
+
client: RtlsClient,
|
|
185
|
+
*,
|
|
186
|
+
start: datetime,
|
|
187
|
+
end: datetime,
|
|
188
|
+
area_uids: list[str],
|
|
189
|
+
stream_to: IO[bytes] | None = None,
|
|
190
|
+
dedup_headers: bool = True,
|
|
191
|
+
) -> CsvBlob | None:
|
|
192
|
+
"""Download Zone-Activity CSV, following ``content-next-page`` chunks.
|
|
193
|
+
|
|
194
|
+
Each chunk after the first carries a duplicate header row by default
|
|
195
|
+
(the server treats each chunk as a self-contained CSV). The SDK
|
|
196
|
+
strips the duplicate so the concatenated bytes parse as one coherent
|
|
197
|
+
CSV. Pass ``dedup_headers=False`` to preserve raw chunks.
|
|
198
|
+
|
|
199
|
+
``stream_to`` (file-like opened in binary mode) receives bytes as
|
|
200
|
+
they arrive; in that case the return value is ``None``. With
|
|
201
|
+
``stream_to=None`` (default) the SDK accumulates in memory and
|
|
202
|
+
returns a :class:`CsvBlob`.
|
|
203
|
+
"""
|
|
204
|
+
params: list[tuple[str, str]] = [
|
|
205
|
+
("from", str(to_epoch_ms(start, arg_name="start"))),
|
|
206
|
+
("to", str(to_epoch_ms(end, arg_name="end"))),
|
|
207
|
+
("type", "information"),
|
|
208
|
+
("format", "csv"),
|
|
209
|
+
]
|
|
210
|
+
params.extend(pack_array_param("area_uid", area_uids))
|
|
211
|
+
|
|
212
|
+
return _download_csv_chunked(
|
|
213
|
+
client,
|
|
214
|
+
path="/api/v2/data/zone_events",
|
|
215
|
+
params=params,
|
|
216
|
+
stream_to=stream_to,
|
|
217
|
+
dedup_headers=dedup_headers,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def download_alarms_csv(
|
|
222
|
+
client: RtlsClient,
|
|
223
|
+
*,
|
|
224
|
+
start: datetime,
|
|
225
|
+
end: datetime,
|
|
226
|
+
site_uid: str,
|
|
227
|
+
alarm_types: list[str] | None = None,
|
|
228
|
+
include_banner: bool = True,
|
|
229
|
+
) -> CsvBlob:
|
|
230
|
+
"""Download Alarms CSV; optionally prepend a multi-line header banner.
|
|
231
|
+
|
|
232
|
+
The banner mirrors the JS reference client's behaviour
|
|
233
|
+
(RESEARCH §4 "Download Alarms CSV with header banner"): a few
|
|
234
|
+
``#Company / #Project / #From / #To`` lines stamped on top so
|
|
235
|
+
downstream tooling can identify the report. Pass
|
|
236
|
+
``include_banner=False`` to get the bare server CSV.
|
|
237
|
+
"""
|
|
238
|
+
params: list[tuple[str, str]] = [
|
|
239
|
+
("from", str(to_epoch_ms(start, arg_name="start"))),
|
|
240
|
+
("to", str(to_epoch_ms(end, arg_name="end"))),
|
|
241
|
+
("location_uid", site_uid),
|
|
242
|
+
("format", "csv"),
|
|
243
|
+
("report", "1"),
|
|
244
|
+
]
|
|
245
|
+
if alarm_types:
|
|
246
|
+
params.extend(pack_array_param("alarm_type_name", alarm_types))
|
|
247
|
+
|
|
248
|
+
# Single-call download — alarms CSV isn't chunked.
|
|
249
|
+
response = client._request("GET", "/api/v2/alarms", params=params)
|
|
250
|
+
content = response.content
|
|
251
|
+
filename = response.headers.get(_FILENAME_HEADER)
|
|
252
|
+
|
|
253
|
+
if include_banner:
|
|
254
|
+
banner_lines = [
|
|
255
|
+
f"#Site: {site_uid}",
|
|
256
|
+
f"#From: {start.isoformat()}",
|
|
257
|
+
f"#To: {end.isoformat()}",
|
|
258
|
+
]
|
|
259
|
+
if alarm_types:
|
|
260
|
+
banner_lines.append(f"#Alarm-types: {','.join(alarm_types)}")
|
|
261
|
+
banner = ("\n".join(banner_lines) + "\n").encode("utf-8")
|
|
262
|
+
content = banner + content
|
|
263
|
+
|
|
264
|
+
return CsvBlob(content=content, filename=filename)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _download_csv_chunked(
|
|
268
|
+
client: RtlsClient,
|
|
269
|
+
*,
|
|
270
|
+
path: str,
|
|
271
|
+
params: list[tuple[str, str]],
|
|
272
|
+
stream_to: IO[bytes] | None,
|
|
273
|
+
dedup_headers: bool,
|
|
274
|
+
) -> CsvBlob | None:
|
|
275
|
+
"""Chase ``content-next-page`` chunks until exhausted.
|
|
276
|
+
|
|
277
|
+
Returns a :class:`CsvBlob` on in-memory mode (``stream_to=None``)
|
|
278
|
+
or ``None`` when streaming to a writable.
|
|
279
|
+
"""
|
|
280
|
+
chunks: list[bytes] = []
|
|
281
|
+
filename: str | None = None
|
|
282
|
+
first = True
|
|
283
|
+
next_path: str | None = path
|
|
284
|
+
next_params: Any = params
|
|
285
|
+
|
|
286
|
+
while next_path is not None:
|
|
287
|
+
response = client._request("GET", next_path, params=next_params)
|
|
288
|
+
if filename is None:
|
|
289
|
+
filename = response.headers.get(_FILENAME_HEADER)
|
|
290
|
+
|
|
291
|
+
chunk = response.content
|
|
292
|
+
if not first and dedup_headers:
|
|
293
|
+
chunk = _strip_first_line(chunk)
|
|
294
|
+
|
|
295
|
+
if stream_to is not None:
|
|
296
|
+
stream_to.write(chunk)
|
|
297
|
+
else:
|
|
298
|
+
chunks.append(chunk)
|
|
299
|
+
|
|
300
|
+
first = False
|
|
301
|
+
next_url = response.headers.get(_NEXT_PAGE_HEADER)
|
|
302
|
+
if next_url and next_url != "null":
|
|
303
|
+
# The header carries an absolute or relative URL — feed it
|
|
304
|
+
# back as the next path. Strip the base_url if it's an
|
|
305
|
+
# absolute URL matching our own host (httpx will resolve
|
|
306
|
+
# both forms cleanly).
|
|
307
|
+
parsed = urllib.parse.urlparse(next_url)
|
|
308
|
+
if parsed.netloc:
|
|
309
|
+
# Absolute URL — pass it through unchanged.
|
|
310
|
+
next_path = next_url
|
|
311
|
+
else:
|
|
312
|
+
next_path = next_url
|
|
313
|
+
next_params = None # URL already includes query string
|
|
314
|
+
else:
|
|
315
|
+
next_path = None
|
|
316
|
+
|
|
317
|
+
if stream_to is not None:
|
|
318
|
+
return None
|
|
319
|
+
return CsvBlob(content=b"".join(chunks), filename=filename)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _strip_first_line(blob: bytes) -> bytes:
|
|
323
|
+
"""Drop the first newline-terminated line from a CSV chunk.
|
|
324
|
+
|
|
325
|
+
Used by :func:`download_zone_activity_csv` to dedup repeated header
|
|
326
|
+
rows when concatenating chunks. Tolerates ``\\r\\n`` and ``\\n``.
|
|
327
|
+
"""
|
|
328
|
+
idx = blob.find(b"\n")
|
|
329
|
+
if idx == -1:
|
|
330
|
+
return b""
|
|
331
|
+
return blob[idx + 1 :]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
__all__ = [
|
|
335
|
+
"download_alarms_csv",
|
|
336
|
+
"download_zone_activity_csv",
|
|
337
|
+
"generate_heatmap",
|
|
338
|
+
"generate_pws_report",
|
|
339
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Compound system / health workflows.
|
|
2
|
+
|
|
3
|
+
Currently ships just :func:`health` — the aggregated
|
|
4
|
+
host/uptime/connections snapshot from DESIGN §4.17. Continue-on-error:
|
|
5
|
+
each sub-call's failure goes into ``SystemHealth.errors`` rather than
|
|
6
|
+
aborting the whole snapshot.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from ..errors import RtlsError
|
|
14
|
+
from ..models import SystemHealth
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .._client import RtlsClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def collect_system_health(client: RtlsClient) -> SystemHealth:
|
|
21
|
+
"""Fan out host / uptime / connections; tolerate per-call failure."""
|
|
22
|
+
result = SystemHealth()
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
result.host = client.system.host()
|
|
26
|
+
except RtlsError as exc:
|
|
27
|
+
result.errors["host"] = exc
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
result.uptime = client.system.uptime()
|
|
31
|
+
except RtlsError as exc:
|
|
32
|
+
result.errors["uptime"] = exc
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
result.connections = client.system.connections()
|
|
36
|
+
except RtlsError as exc:
|
|
37
|
+
result.errors["connections"] = exc
|
|
38
|
+
|
|
39
|
+
result.partial = bool(result.errors)
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["collect_system_health"]
|