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,157 @@
|
|
|
1
|
+
"""Nodes resource sub-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
9
|
+
from .._query import normalize_mac
|
|
10
|
+
from ..compounds import nodes as _compound
|
|
11
|
+
from ..models import BulkResult, ImportResult, Node
|
|
12
|
+
from ._base import ResourceAPI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NodesAPI(ResourceAPI):
|
|
16
|
+
"""Read and (with care) mutate hardware nodes.
|
|
17
|
+
|
|
18
|
+
The JS reference client fetches nodes at company scope, not project
|
|
19
|
+
scope. The SDK doesn't auto-switch — call
|
|
20
|
+
``client.with_scope(company_uid=...)`` if you need the company-wide
|
|
21
|
+
view. (``with_scope`` lands in M8.)
|
|
22
|
+
|
|
23
|
+
M5 adds compound :meth:`release` (close-association + release in one
|
|
24
|
+
call, with per-call scope) and :meth:`import_` (validate + create
|
|
25
|
+
loop). ``list`` is defined last (class-scope shadow workaround).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def create(self, mac_address: str, **fields: Any) -> Node:
|
|
29
|
+
"""Register a new node by its MAC address.
|
|
30
|
+
|
|
31
|
+
The MAC is the only required input. The server's ``createNode``
|
|
32
|
+
schema accepts other fields (``name``, ``hw_info``,
|
|
33
|
+
``parent_mac_address``, ``bridge_mac_address``, ``announce``,
|
|
34
|
+
``node_type``, ``periferial_info``, ``uwb_trx_config``,
|
|
35
|
+
``company_uid``, ``project_uid``) via ``**fields``, but the
|
|
36
|
+
canonical commissioning flow is MAC-only; metadata is added
|
|
37
|
+
later via :meth:`update_raw`.
|
|
38
|
+
|
|
39
|
+
The MAC is normalized to bare 12-hex lowercase (the server's
|
|
40
|
+
validation regex is ``/^[a-fA-F0-9]{12}$/`` — no separators).
|
|
41
|
+
Six input formats accepted; malformed input raises
|
|
42
|
+
:class:`ValueError` before any HTTP call.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> node = client.nodes.create("aabbccddeeff")
|
|
47
|
+
>>> node.mac_address
|
|
48
|
+
'aabbccddeeff'
|
|
49
|
+
"""
|
|
50
|
+
body: dict[str, Any] = {"mac_address": normalize_mac(mac_address), **fields}
|
|
51
|
+
response = self._post("/api/v2/nodes/", json=body)
|
|
52
|
+
payload = response.json()
|
|
53
|
+
try:
|
|
54
|
+
item = unwrap_resource_single(payload, "node")
|
|
55
|
+
except ValueError:
|
|
56
|
+
item = payload if isinstance(payload, dict) else {}
|
|
57
|
+
return Node.from_wire(item)
|
|
58
|
+
|
|
59
|
+
def delete_raw(self, uid: str) -> None:
|
|
60
|
+
"""Delete a node (single REST call, fail-fast).
|
|
61
|
+
|
|
62
|
+
**Advanced.** :meth:`delete` (added in M5) is the
|
|
63
|
+
continue-on-error bulk variant.
|
|
64
|
+
"""
|
|
65
|
+
self._delete(f"/api/v2/nodes/{uid}")
|
|
66
|
+
|
|
67
|
+
def validate(self, mac_addresses: list[str]) -> dict[str, Any]:
|
|
68
|
+
"""Validate one or more MAC addresses against the server.
|
|
69
|
+
|
|
70
|
+
Returns the raw server response (typically containing ``valid``,
|
|
71
|
+
``invalid``, ``duplicate`` lists). M5's :meth:`import_` compound
|
|
72
|
+
wraps this.
|
|
73
|
+
"""
|
|
74
|
+
normalized = [normalize_mac(m) for m in mac_addresses]
|
|
75
|
+
response = self._post("/api/v2/nodes/validate/", json={"mac_addresses": normalized})
|
|
76
|
+
payload = response.json()
|
|
77
|
+
if isinstance(payload, dict):
|
|
78
|
+
return payload
|
|
79
|
+
return {"data": payload}
|
|
80
|
+
|
|
81
|
+
def assign_raw(self, mac_address: str, *, project_uid: str) -> dict[str, Any]:
|
|
82
|
+
"""Assign a node to a project (single REST call).
|
|
83
|
+
|
|
84
|
+
**Advanced.** See M5's :meth:`release` for the inverse, which is
|
|
85
|
+
wrapped as a compound to avoid the JS client's scope-mutation bug.
|
|
86
|
+
"""
|
|
87
|
+
body = {"mac_address": normalize_mac(mac_address), "project_uid": project_uid}
|
|
88
|
+
response = self._post("/api/v2/nodes_associations/assign", json=body)
|
|
89
|
+
payload = response.json()
|
|
90
|
+
return payload if isinstance(payload, dict) else {"data": payload}
|
|
91
|
+
|
|
92
|
+
def release_raw(self, mac_address: str, *, project_uid: str) -> dict[str, Any]:
|
|
93
|
+
"""Release a node from a project (single REST call).
|
|
94
|
+
|
|
95
|
+
**Advanced.** This does NOT close any open trackable-association
|
|
96
|
+
for the MAC first — :meth:`release` (M5) does both safely with
|
|
97
|
+
per-call scope.
|
|
98
|
+
"""
|
|
99
|
+
body = {"mac_address": normalize_mac(mac_address), "project_uid": project_uid}
|
|
100
|
+
response = self._post("/api/v2/nodes_associations/release", json=body)
|
|
101
|
+
payload = response.json()
|
|
102
|
+
return payload if isinstance(payload, dict) else {"data": payload}
|
|
103
|
+
|
|
104
|
+
def release(self, mac_address: str, *, project_uid: str | None = None) -> None:
|
|
105
|
+
"""Release a node from a project, closing any open association.
|
|
106
|
+
|
|
107
|
+
The compound first looks up the open trackable-association for
|
|
108
|
+
``mac_address`` and closes it; only then does it release the
|
|
109
|
+
node. All sub-calls run under a per-call scope override so the
|
|
110
|
+
target project travels in the request headers — the JS
|
|
111
|
+
reference client mutates ``axios.defaults`` here and leaks the
|
|
112
|
+
change on error (RESEARCH §4 "Release node and close its
|
|
113
|
+
association"). The SDK's per-call scope is per-thread and
|
|
114
|
+
reverts on exit.
|
|
115
|
+
|
|
116
|
+
Step names: ``close_association``, ``release_node``.
|
|
117
|
+
"""
|
|
118
|
+
_compound.release_node(self._client, normalize_mac(mac_address), project_uid=project_uid)
|
|
119
|
+
|
|
120
|
+
def import_(
|
|
121
|
+
self,
|
|
122
|
+
mac_addresses: list[str],
|
|
123
|
+
*,
|
|
124
|
+
on_progress: Callable[[int, int], None] | None = None,
|
|
125
|
+
) -> ImportResult:
|
|
126
|
+
"""Validate MAC addresses, then create each valid row.
|
|
127
|
+
|
|
128
|
+
Short-circuits if any MAC fails validation — no partial creates
|
|
129
|
+
when the input has known-bad rows. ``on_progress(done, total)``
|
|
130
|
+
(optional) is invoked after each create attempt.
|
|
131
|
+
|
|
132
|
+
Returns
|
|
133
|
+
-------
|
|
134
|
+
ImportResult
|
|
135
|
+
Lists of valid / invalid / duplicate MACs, the number of
|
|
136
|
+
nodes actually created, and per-row failures from the
|
|
137
|
+
create step.
|
|
138
|
+
"""
|
|
139
|
+
normalized = [normalize_mac(m) for m in mac_addresses]
|
|
140
|
+
return _compound.import_nodes(self._client, normalized, on_progress=on_progress)
|
|
141
|
+
|
|
142
|
+
def delete(self, uid_or_uids: str | list[str]) -> BulkResult[None]:
|
|
143
|
+
"""Delete one or many nodes, continue-on-error.
|
|
144
|
+
|
|
145
|
+
Diverges from :meth:`delete_raw` (M3) which is fail-fast: this
|
|
146
|
+
compound continues past per-item failures and returns a
|
|
147
|
+
:class:`BulkResult` listing successes and failures separately.
|
|
148
|
+
"""
|
|
149
|
+
return _compound.delete_nodes(self._client, uid_or_uids)
|
|
150
|
+
|
|
151
|
+
def list(self) -> list[Node]:
|
|
152
|
+
response = self._get("/api/v2/nodes")
|
|
153
|
+
items = unwrap_resource(response.json(), "nodes")
|
|
154
|
+
return [Node.from_wire(item) for item in items]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["NodesAPI"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Notifications resource sub-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
8
|
+
from ..models import NotificationProfile, NotificationType
|
|
9
|
+
from ._base import ResourceAPI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NotificationsAPI(ResourceAPI):
|
|
13
|
+
"""Read and manage notification profiles + notification-type metadata.
|
|
14
|
+
|
|
15
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def create(self, name: str, **fields: Any) -> NotificationProfile:
|
|
19
|
+
"""Create a notification profile."""
|
|
20
|
+
body: dict[str, Any] = {"name": name, **fields}
|
|
21
|
+
response = self._post("/api/v2/notifications/", json=body)
|
|
22
|
+
payload = response.json()
|
|
23
|
+
try:
|
|
24
|
+
item = unwrap_resource_single(payload, "notification")
|
|
25
|
+
except ValueError:
|
|
26
|
+
item = payload if isinstance(payload, dict) else {}
|
|
27
|
+
return NotificationProfile.from_wire(item)
|
|
28
|
+
|
|
29
|
+
def update(self, uid: str, **fields: Any) -> NotificationProfile:
|
|
30
|
+
"""Update a notification profile."""
|
|
31
|
+
response = self._put(f"/api/v2/notifications/{uid}", json=fields)
|
|
32
|
+
payload = response.json()
|
|
33
|
+
try:
|
|
34
|
+
item = unwrap_resource_single(payload, "notification")
|
|
35
|
+
except ValueError:
|
|
36
|
+
item = payload if isinstance(payload, dict) else {}
|
|
37
|
+
return NotificationProfile.from_wire(item)
|
|
38
|
+
|
|
39
|
+
def delete(self, uid: str) -> None:
|
|
40
|
+
"""Delete a notification profile."""
|
|
41
|
+
self._delete(f"/api/v2/notifications/{uid}")
|
|
42
|
+
|
|
43
|
+
def list_types(self) -> list[NotificationType]:
|
|
44
|
+
"""Enumerate the kinds of events notifications can subscribe to."""
|
|
45
|
+
response = self._get("/api/v2/notification_types")
|
|
46
|
+
items = unwrap_resource(response.json(), "notification_types")
|
|
47
|
+
return [NotificationType.from_wire(item) for item in items]
|
|
48
|
+
|
|
49
|
+
def list(self) -> list[NotificationProfile]:
|
|
50
|
+
response = self._get("/api/v2/notifications/")
|
|
51
|
+
items = unwrap_resource(response.json(), "notifications")
|
|
52
|
+
return [NotificationProfile.from_wire(item) for item in items]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = ["NotificationsAPI"]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Projects resource sub-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
8
|
+
from ..models import Project
|
|
9
|
+
from ._base import ResourceAPI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectsAPI(ResourceAPI):
|
|
13
|
+
"""List, create, update, and delete projects.
|
|
14
|
+
|
|
15
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def create(self, name: str, **fields: Any) -> Project:
|
|
19
|
+
"""Create a new project.
|
|
20
|
+
|
|
21
|
+
Server's Joi ``createProject`` schema expects the body nested
|
|
22
|
+
under a top-level ``project`` key:
|
|
23
|
+
``{"project": {"name": "...", ...}}``. The SDK wraps the body so
|
|
24
|
+
callers can pass flat kwargs. M14 regression — phase-1 failure
|
|
25
|
+
was the server returning ``"project" is required``.
|
|
26
|
+
"""
|
|
27
|
+
body: dict[str, Any] = {"project": {"name": name, **fields}}
|
|
28
|
+
response = self._post("/api/v2/projects/", json=body)
|
|
29
|
+
payload = response.json()
|
|
30
|
+
try:
|
|
31
|
+
item = unwrap_resource_single(payload, "project")
|
|
32
|
+
except ValueError:
|
|
33
|
+
item = payload if isinstance(payload, dict) else {}
|
|
34
|
+
return Project.from_wire(item)
|
|
35
|
+
|
|
36
|
+
def update(self, uid: str, **fields: Any) -> Project:
|
|
37
|
+
"""Update a project's fields.
|
|
38
|
+
|
|
39
|
+
Body is nested under ``project`` to match
|
|
40
|
+
:meth:`create` / the Joi ``updateProject`` schema.
|
|
41
|
+
"""
|
|
42
|
+
body: dict[str, Any] = {"project": fields}
|
|
43
|
+
response = self._put(f"/api/v2/projects/{uid}", json=body)
|
|
44
|
+
payload = response.json()
|
|
45
|
+
try:
|
|
46
|
+
item = unwrap_resource_single(payload, "project")
|
|
47
|
+
except ValueError:
|
|
48
|
+
item = payload if isinstance(payload, dict) else {}
|
|
49
|
+
return Project.from_wire(item)
|
|
50
|
+
|
|
51
|
+
def delete(self, uid: str) -> None:
|
|
52
|
+
"""Delete a project."""
|
|
53
|
+
self._delete(f"/api/v2/projects/{uid}")
|
|
54
|
+
|
|
55
|
+
def list(self) -> list[Project]:
|
|
56
|
+
"""Return projects visible to the current user.
|
|
57
|
+
|
|
58
|
+
Project listing does not require an active ``X-User-Project`` header
|
|
59
|
+
(RESEARCH §1 Projects row 1) — it's commonly the first scoped call
|
|
60
|
+
after login.
|
|
61
|
+
"""
|
|
62
|
+
response = self._get("/api/v2/projects/")
|
|
63
|
+
items = unwrap_resource(response.json(), "projects")
|
|
64
|
+
return [Project.from_wire(item) for item in items]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["ProjectsAPI"]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Reports resource sub-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import IO
|
|
7
|
+
|
|
8
|
+
from .._envelope import unwrap_resource_single
|
|
9
|
+
from .._time import to_iso8601
|
|
10
|
+
from ..compounds import reports as _compound
|
|
11
|
+
from ..models import CsvBlob, Heatmap, PwsReport, Report, ReportType
|
|
12
|
+
from ._base import ResourceAPI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ReportsAPI(ResourceAPI):
|
|
16
|
+
"""Generate and read reports.
|
|
17
|
+
|
|
18
|
+
M2 shipped read-only access (``list``, ``get``). M7 adds the
|
|
19
|
+
compound flows: heatmap polling, PWS aggregation, CSV downloads
|
|
20
|
+
(zone-activity, alarms).
|
|
21
|
+
|
|
22
|
+
``list`` is defined last (class-scope shadow workaround for mypy).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def get(self, uid: str, *, is_output_file: bool = False) -> Report:
|
|
26
|
+
"""Fetch a single report's metadata."""
|
|
27
|
+
params = {"file": "1"} if is_output_file else None
|
|
28
|
+
response = self._get(f"/api/v2/report/{uid}", params=params)
|
|
29
|
+
payload = response.json()
|
|
30
|
+
if isinstance(payload, dict) and "report" in payload:
|
|
31
|
+
return Report.from_wire(unwrap_resource_single(payload, "report"))
|
|
32
|
+
if isinstance(payload, dict):
|
|
33
|
+
return Report.from_wire(payload)
|
|
34
|
+
raise ValueError(f"unexpected report payload shape: {type(payload).__name__}")
|
|
35
|
+
|
|
36
|
+
# -- M7 compounds --------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def heatmap(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
start: datetime,
|
|
42
|
+
end: datetime,
|
|
43
|
+
tag_uids: list[str],
|
|
44
|
+
names: list[str] | None = None,
|
|
45
|
+
poll_interval: float = 2.0,
|
|
46
|
+
timeout: float | None = None,
|
|
47
|
+
) -> Heatmap:
|
|
48
|
+
"""Generate a heatmap report — POST, then poll until ready.
|
|
49
|
+
|
|
50
|
+
Uses the live ``/api/v2/obsolete/report`` paths (RESEARCH
|
|
51
|
+
Discrepancy #5). Default ``timeout=None`` is unbounded; pass a
|
|
52
|
+
finite value to bound the wait. On timeout the SDK raises
|
|
53
|
+
:class:`ReportTimeout` carrying the in-flight ``report_uid`` so
|
|
54
|
+
the caller can resume polling via :meth:`get` later.
|
|
55
|
+
|
|
56
|
+
On a server-reported error the SDK raises :class:`ReportFailed`.
|
|
57
|
+
|
|
58
|
+
Examples
|
|
59
|
+
--------
|
|
60
|
+
>>> heatmap = client.reports.heatmap(
|
|
61
|
+
... start=start, end=end, tag_uids=["t-1", "t-2"], timeout=300,
|
|
62
|
+
... )
|
|
63
|
+
>>> heatmap.image_url
|
|
64
|
+
'https://rtls.example.com/api/v2/heatmap/h-1?...'
|
|
65
|
+
"""
|
|
66
|
+
return _compound.generate_heatmap(
|
|
67
|
+
self._client,
|
|
68
|
+
start=start,
|
|
69
|
+
end=end,
|
|
70
|
+
tag_uids=tag_uids,
|
|
71
|
+
names=names,
|
|
72
|
+
poll_interval=poll_interval,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def pws(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
trackable_uid: str | list[str],
|
|
80
|
+
start: datetime,
|
|
81
|
+
end: datetime,
|
|
82
|
+
timezone: str | None = None,
|
|
83
|
+
) -> PwsReport:
|
|
84
|
+
"""Aggregate all PWS-event pages into a :class:`PwsReport`.
|
|
85
|
+
|
|
86
|
+
Holds the full result set in memory. For large windows prefer
|
|
87
|
+
:meth:`EventsAPI.iter_pws` (lazy).
|
|
88
|
+
"""
|
|
89
|
+
return _compound.generate_pws_report(
|
|
90
|
+
self._client,
|
|
91
|
+
trackable_uid=trackable_uid,
|
|
92
|
+
start=start,
|
|
93
|
+
end=end,
|
|
94
|
+
timezone=timezone,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def zone_activity_csv(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
start: datetime,
|
|
101
|
+
end: datetime,
|
|
102
|
+
area_uids: list[str],
|
|
103
|
+
stream_to: IO[bytes] | None = None,
|
|
104
|
+
dedup_headers: bool = True,
|
|
105
|
+
) -> CsvBlob | None:
|
|
106
|
+
"""Download Zone-Activity CSV, following ``content-next-page`` chunks.
|
|
107
|
+
|
|
108
|
+
With ``stream_to=None`` (default) the SDK accumulates in memory
|
|
109
|
+
and returns a :class:`CsvBlob`. With ``stream_to=<file-like>``
|
|
110
|
+
chunks are written as they arrive and the return is ``None``.
|
|
111
|
+
|
|
112
|
+
Duplicate header rows in chunks 2..N are stripped by default
|
|
113
|
+
(``dedup_headers=True``) so the concatenated bytes parse as one
|
|
114
|
+
coherent CSV.
|
|
115
|
+
"""
|
|
116
|
+
return _compound.download_zone_activity_csv(
|
|
117
|
+
self._client,
|
|
118
|
+
start=start,
|
|
119
|
+
end=end,
|
|
120
|
+
area_uids=area_uids,
|
|
121
|
+
stream_to=stream_to,
|
|
122
|
+
dedup_headers=dedup_headers,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def alarms_csv(
|
|
126
|
+
self,
|
|
127
|
+
*,
|
|
128
|
+
start: datetime,
|
|
129
|
+
end: datetime,
|
|
130
|
+
site_uid: str,
|
|
131
|
+
alarm_types: list[str] | None = None,
|
|
132
|
+
include_banner: bool = True,
|
|
133
|
+
) -> CsvBlob:
|
|
134
|
+
"""Download Alarms CSV with an optional multi-line banner prefix.
|
|
135
|
+
|
|
136
|
+
The banner mirrors the JS reference client's behaviour
|
|
137
|
+
(``#Site / #From / #To`` lines) so downstream tooling can
|
|
138
|
+
identify the report. Pass ``include_banner=False`` for the
|
|
139
|
+
bare server CSV.
|
|
140
|
+
"""
|
|
141
|
+
return _compound.download_alarms_csv(
|
|
142
|
+
self._client,
|
|
143
|
+
start=start,
|
|
144
|
+
end=end,
|
|
145
|
+
site_uid=site_uid,
|
|
146
|
+
alarm_types=alarm_types,
|
|
147
|
+
include_banner=include_banner,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def list(
|
|
151
|
+
self,
|
|
152
|
+
*,
|
|
153
|
+
site_uid: str,
|
|
154
|
+
start: datetime,
|
|
155
|
+
end: datetime,
|
|
156
|
+
type: ReportType,
|
|
157
|
+
) -> list[Report]:
|
|
158
|
+
"""List reports of ``type`` generated for ``site_uid`` in a window.
|
|
159
|
+
|
|
160
|
+
Timestamps are sent as ISO-8601 strings (RESEARCH §2 — reports
|
|
161
|
+
diverge from alarms here). Naive ``datetime`` raises ``ValueError``.
|
|
162
|
+
"""
|
|
163
|
+
params = {
|
|
164
|
+
"site_uid": site_uid,
|
|
165
|
+
"from": to_iso8601(start, arg_name="start"),
|
|
166
|
+
"to": to_iso8601(end, arg_name="end"),
|
|
167
|
+
"type": type.value,
|
|
168
|
+
}
|
|
169
|
+
response = self._get("/api/v2/report", params=params)
|
|
170
|
+
payload = response.json()
|
|
171
|
+
if isinstance(payload, dict):
|
|
172
|
+
items = payload.get("reports")
|
|
173
|
+
if isinstance(items, list):
|
|
174
|
+
return [Report.from_wire(item) for item in items]
|
|
175
|
+
if isinstance(payload, list):
|
|
176
|
+
return [Report.from_wire(item) for item in payload]
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
__all__ = ["ReportsAPI"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Sites (locations) resource sub-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
8
|
+
from ..models import Site
|
|
9
|
+
from ._base import ResourceAPI
|
|
10
|
+
|
|
11
|
+
# The JS reference client only sends ``{name, shift}`` on a site update,
|
|
12
|
+
# even though the server (per docs) accepts more. The SDK matches the
|
|
13
|
+
# client's conservatism to avoid surprising server-side validation
|
|
14
|
+
# failures. RESEARCH §1 / Discrepancy #13. The constraint is on UPDATE
|
|
15
|
+
# only — ``create`` accepts a wider body because the desktop site-plan
|
|
16
|
+
# client sends additional fields (address, country, timezone, …) at
|
|
17
|
+
# creation time.
|
|
18
|
+
_SITE_UPDATE_FIELDS: frozenset[str] = frozenset({"name", "shift"})
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SitesAPI(ResourceAPI):
|
|
22
|
+
"""List, create, update, and delete physical sites in the current scope.
|
|
23
|
+
|
|
24
|
+
The wire-format name is ``locations``; SDK exposes them as ``sites``
|
|
25
|
+
because that's the operator-facing term.
|
|
26
|
+
|
|
27
|
+
Cascade contract: ``sites.delete(uid)`` deletes the site **and**
|
|
28
|
+
every area, floorplan, anchor, and zone under it (server-side
|
|
29
|
+
cascade). The SDK does not pre-emptively delete children.
|
|
30
|
+
|
|
31
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def create(self, name: str, *, shift: dict[str, Any] | None = None, **fields: Any) -> Site:
|
|
35
|
+
"""Create a site.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
name
|
|
40
|
+
Display name.
|
|
41
|
+
shift
|
|
42
|
+
Optional shift configuration (the JS reference client's only
|
|
43
|
+
documented update field — passing it on create is also legal).
|
|
44
|
+
**fields
|
|
45
|
+
Additional fields the deployment supports (``address``,
|
|
46
|
+
``country``, ``timezone``, …). The desktop site-plan client
|
|
47
|
+
sends these on create; the server's create surface is wider
|
|
48
|
+
than its update surface, so no allowlist is enforced here.
|
|
49
|
+
|
|
50
|
+
Examples
|
|
51
|
+
--------
|
|
52
|
+
>>> site = client.sites.create(name="Warehouse 12")
|
|
53
|
+
>>> site_with_addr = client.sites.create(
|
|
54
|
+
... name="Warehouse 13", address="123 Main", country="US",
|
|
55
|
+
... )
|
|
56
|
+
"""
|
|
57
|
+
body: dict[str, Any] = {"name": name, **fields}
|
|
58
|
+
if shift is not None:
|
|
59
|
+
body["shift"] = shift
|
|
60
|
+
response = self._post("/api/v2/locations/", json=body)
|
|
61
|
+
payload = response.json()
|
|
62
|
+
try:
|
|
63
|
+
item = unwrap_resource_single(payload, "location")
|
|
64
|
+
except ValueError:
|
|
65
|
+
item = payload if isinstance(payload, dict) else {}
|
|
66
|
+
return Site.from_wire(item)
|
|
67
|
+
|
|
68
|
+
def update(self, uid: str, **fields: Any) -> Site:
|
|
69
|
+
"""Update a site's name and / or shift configuration.
|
|
70
|
+
|
|
71
|
+
Only ``name`` and ``shift`` are accepted — passing any other
|
|
72
|
+
field raises :class:`ValueError` *before* any HTTP call is made.
|
|
73
|
+
This mirrors the JS reference client's conservative payload
|
|
74
|
+
(RESEARCH Discrepancy #13). If you have a use case that needs
|
|
75
|
+
other fields updated, file an issue — the SDK can be opened up
|
|
76
|
+
once the server-side contract is confirmed.
|
|
77
|
+
"""
|
|
78
|
+
extra = set(fields) - _SITE_UPDATE_FIELDS
|
|
79
|
+
if extra:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"sites.update only accepts {sorted(_SITE_UPDATE_FIELDS)}; "
|
|
82
|
+
f"got unexpected field(s): {sorted(extra)}"
|
|
83
|
+
)
|
|
84
|
+
response = self._put(f"/api/v2/locations/{uid}", json=fields)
|
|
85
|
+
payload = response.json()
|
|
86
|
+
try:
|
|
87
|
+
item = unwrap_resource_single(payload, "location")
|
|
88
|
+
except ValueError:
|
|
89
|
+
item = payload if isinstance(payload, dict) else {}
|
|
90
|
+
return Site.from_wire(item)
|
|
91
|
+
|
|
92
|
+
def delete(self, uid: str) -> None:
|
|
93
|
+
"""Delete a site (cascades to areas / floorplans / anchors / zones server-side).
|
|
94
|
+
|
|
95
|
+
A subsequent GET returns 404 → :class:`NotFound`. The cascade is
|
|
96
|
+
the server's contract, not the SDK's — the SDK does NOT iterate
|
|
97
|
+
children before issuing the DELETE.
|
|
98
|
+
"""
|
|
99
|
+
self._delete(f"/api/v2/locations/{uid}")
|
|
100
|
+
|
|
101
|
+
def list(self) -> list[Site]:
|
|
102
|
+
"""Return all sites visible to the current project scope.
|
|
103
|
+
|
|
104
|
+
Examples
|
|
105
|
+
--------
|
|
106
|
+
>>> for site in client.sites.list():
|
|
107
|
+
... print(site.uid, site.name)
|
|
108
|
+
"""
|
|
109
|
+
response = self._get("/api/v2/locations/")
|
|
110
|
+
payload = response.json()
|
|
111
|
+
items = unwrap_resource(payload, "locations")
|
|
112
|
+
return [Site.from_wire(item) for item in items]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
__all__ = ["SitesAPI"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Subscribers resource sub-client.
|
|
2
|
+
|
|
3
|
+
Two distinct sub-surfaces:
|
|
4
|
+
|
|
5
|
+
- ``subscribers`` — alarm-notification subscribers; envelope key
|
|
6
|
+
``subscribers``.
|
|
7
|
+
- system-notification subscribers — envelope key
|
|
8
|
+
``systemNotificationSubscriber``. Returned as a list of
|
|
9
|
+
:class:`SystemSubscriber`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
17
|
+
from ..models import Subscriber, SystemSubscriber
|
|
18
|
+
from ._base import ResourceAPI
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SubscribersAPI(ResourceAPI):
|
|
22
|
+
"""Read and manage alarm subscribers and system-notification subscribers.
|
|
23
|
+
|
|
24
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def create(self, name: str, **fields: Any) -> Subscriber:
|
|
28
|
+
"""Create an alarm subscriber.
|
|
29
|
+
|
|
30
|
+
``fields`` typically contains ``addresses`` (``{"email": [...],
|
|
31
|
+
"phone": [...]}``) and a ``notifications`` list.
|
|
32
|
+
"""
|
|
33
|
+
body: dict[str, Any] = {"name": name, **fields}
|
|
34
|
+
response = self._post("/api/v2/subscribers/", json=body)
|
|
35
|
+
payload = response.json()
|
|
36
|
+
try:
|
|
37
|
+
item = unwrap_resource_single(payload, "subscriber")
|
|
38
|
+
except ValueError:
|
|
39
|
+
item = payload if isinstance(payload, dict) else {}
|
|
40
|
+
return Subscriber.from_wire(item)
|
|
41
|
+
|
|
42
|
+
def update(self, uid: str, **fields: Any) -> Subscriber:
|
|
43
|
+
"""Update an alarm subscriber."""
|
|
44
|
+
response = self._put(f"/api/v2/subscribers/{uid}", json=fields)
|
|
45
|
+
payload = response.json()
|
|
46
|
+
try:
|
|
47
|
+
item = unwrap_resource_single(payload, "subscriber")
|
|
48
|
+
except ValueError:
|
|
49
|
+
item = payload if isinstance(payload, dict) else {}
|
|
50
|
+
return Subscriber.from_wire(item)
|
|
51
|
+
|
|
52
|
+
def delete(self, uid: str) -> None:
|
|
53
|
+
"""Delete an alarm subscriber."""
|
|
54
|
+
self._delete(f"/api/v2/subscribers/{uid}")
|
|
55
|
+
|
|
56
|
+
def update_system_notifications(
|
|
57
|
+
self, user_uid: str, notifications: list[Any]
|
|
58
|
+
) -> SystemSubscriber:
|
|
59
|
+
"""Update a user's system-event subscriptions.
|
|
60
|
+
|
|
61
|
+
System-event subscriptions live on a separate endpoint
|
|
62
|
+
(``/system_notifications/``) with a different envelope shape
|
|
63
|
+
(RESEARCH §3 Subscriber normalization).
|
|
64
|
+
"""
|
|
65
|
+
body = {"user_uid": user_uid, "notifications": notifications}
|
|
66
|
+
response = self._put("/api/v2/system_notifications/", json=body)
|
|
67
|
+
payload = response.json()
|
|
68
|
+
if isinstance(payload, dict):
|
|
69
|
+
inner = payload.get("systemNotificationSubscriber")
|
|
70
|
+
if isinstance(inner, list) and inner:
|
|
71
|
+
first = inner[0]
|
|
72
|
+
if isinstance(first, dict):
|
|
73
|
+
return SystemSubscriber.from_wire(first)
|
|
74
|
+
if isinstance(inner, dict):
|
|
75
|
+
return SystemSubscriber.from_wire(inner)
|
|
76
|
+
return SystemSubscriber.from_wire(payload)
|
|
77
|
+
return SystemSubscriber.from_wire({})
|
|
78
|
+
|
|
79
|
+
def list_system_notification_subscribers(self) -> list[SystemSubscriber]:
|
|
80
|
+
"""List system-event subscribers (distinct from alarm subscribers).
|
|
81
|
+
|
|
82
|
+
The server's envelope here is ``systemNotificationSubscriber``
|
|
83
|
+
(singular noun, list value) — RESEARCH §3 Subscriber normalization.
|
|
84
|
+
"""
|
|
85
|
+
response = self._get("/api/v2/system_notifications/")
|
|
86
|
+
payload = response.json()
|
|
87
|
+
items: list[Any]
|
|
88
|
+
if isinstance(payload, dict):
|
|
89
|
+
raw = payload.get("systemNotificationSubscriber")
|
|
90
|
+
if raw is None:
|
|
91
|
+
raw = payload.get("system_notification_subscriber")
|
|
92
|
+
if isinstance(raw, list):
|
|
93
|
+
items = raw
|
|
94
|
+
elif isinstance(raw, dict):
|
|
95
|
+
items = [raw]
|
|
96
|
+
else:
|
|
97
|
+
items = []
|
|
98
|
+
elif isinstance(payload, list):
|
|
99
|
+
items = payload
|
|
100
|
+
else:
|
|
101
|
+
items = []
|
|
102
|
+
return [SystemSubscriber.from_wire(item) for item in items]
|
|
103
|
+
|
|
104
|
+
def list(self) -> list[Subscriber]:
|
|
105
|
+
response = self._get("/api/v2/subscribers/")
|
|
106
|
+
items = unwrap_resource(response.json(), "subscribers")
|
|
107
|
+
return [Subscriber.from_wire(item) for item in items]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = ["SubscribersAPI"]
|