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.
Files changed (78) hide show
  1. rtls_sdk/__init__.py +123 -0
  2. rtls_sdk/_auth.py +266 -0
  3. rtls_sdk/_client.py +419 -0
  4. rtls_sdk/_envelope.py +74 -0
  5. rtls_sdk/_http.py +145 -0
  6. rtls_sdk/_logging.py +143 -0
  7. rtls_sdk/_pagination.py +235 -0
  8. rtls_sdk/_query.py +114 -0
  9. rtls_sdk/_time.py +84 -0
  10. rtls_sdk/compounds/__init__.py +19 -0
  11. rtls_sdk/compounds/auth.py +100 -0
  12. rtls_sdk/compounds/context.py +159 -0
  13. rtls_sdk/compounds/groups.py +126 -0
  14. rtls_sdk/compounds/nodes.py +176 -0
  15. rtls_sdk/compounds/reports.py +339 -0
  16. rtls_sdk/compounds/system.py +43 -0
  17. rtls_sdk/compounds/tags.py +404 -0
  18. rtls_sdk/compounds/users.py +143 -0
  19. rtls_sdk/compounds/zones.py +203 -0
  20. rtls_sdk/errors.py +238 -0
  21. rtls_sdk/models/__init__.py +73 -0
  22. rtls_sdk/models/_base.py +46 -0
  23. rtls_sdk/models/alarm.py +23 -0
  24. rtls_sdk/models/anchor.py +25 -0
  25. rtls_sdk/models/area.py +28 -0
  26. rtls_sdk/models/association.py +48 -0
  27. rtls_sdk/models/bulk.py +80 -0
  28. rtls_sdk/models/company.py +32 -0
  29. rtls_sdk/models/csv_blob.py +40 -0
  30. rtls_sdk/models/floorplan.py +42 -0
  31. rtls_sdk/models/group.py +20 -0
  32. rtls_sdk/models/heatmap.py +37 -0
  33. rtls_sdk/models/import_result.py +42 -0
  34. rtls_sdk/models/node.py +28 -0
  35. rtls_sdk/models/notification.py +38 -0
  36. rtls_sdk/models/position.py +66 -0
  37. rtls_sdk/models/project.py +26 -0
  38. rtls_sdk/models/pws.py +38 -0
  39. rtls_sdk/models/report.py +34 -0
  40. rtls_sdk/models/session_context.py +81 -0
  41. rtls_sdk/models/site.py +31 -0
  42. rtls_sdk/models/subscriber.py +68 -0
  43. rtls_sdk/models/system.py +97 -0
  44. rtls_sdk/models/system_health.py +36 -0
  45. rtls_sdk/models/tag.py +48 -0
  46. rtls_sdk/models/tag_template.py +24 -0
  47. rtls_sdk/models/user.py +121 -0
  48. rtls_sdk/models/zone.py +27 -0
  49. rtls_sdk/models/zone_event.py +21 -0
  50. rtls_sdk/py.typed +0 -0
  51. rtls_sdk/resources/__init__.py +49 -0
  52. rtls_sdk/resources/_base.py +63 -0
  53. rtls_sdk/resources/alarms.py +108 -0
  54. rtls_sdk/resources/anchors.py +147 -0
  55. rtls_sdk/resources/areas.py +78 -0
  56. rtls_sdk/resources/associations.py +157 -0
  57. rtls_sdk/resources/auth.py +63 -0
  58. rtls_sdk/resources/companies.py +79 -0
  59. rtls_sdk/resources/context.py +50 -0
  60. rtls_sdk/resources/events.py +149 -0
  61. rtls_sdk/resources/floorplans.py +283 -0
  62. rtls_sdk/resources/groups.py +99 -0
  63. rtls_sdk/resources/logger.py +40 -0
  64. rtls_sdk/resources/messaging.py +51 -0
  65. rtls_sdk/resources/nodes.py +157 -0
  66. rtls_sdk/resources/notifications.py +55 -0
  67. rtls_sdk/resources/projects.py +67 -0
  68. rtls_sdk/resources/reports.py +180 -0
  69. rtls_sdk/resources/sites.py +115 -0
  70. rtls_sdk/resources/subscribers.py +110 -0
  71. rtls_sdk/resources/system.py +125 -0
  72. rtls_sdk/resources/tags.py +370 -0
  73. rtls_sdk/resources/users.py +275 -0
  74. rtls_sdk/resources/zones.py +199 -0
  75. rtls_sdk-0.2.0.dist-info/METADATA +141 -0
  76. rtls_sdk-0.2.0.dist-info/RECORD +78 -0
  77. rtls_sdk-0.2.0.dist-info/WHEEL +4 -0
  78. 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"]