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,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"]