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,147 @@
|
|
|
1
|
+
"""WIN anchors resource sub-client.
|
|
2
|
+
|
|
3
|
+
A WIN anchor is a **placed beacon**, identified by its server-issued
|
|
4
|
+
``uid`` — *not* by a MAC address. The MAC address belongs to a
|
|
5
|
+
:class:`Node` (the hardware), and the binding between a node and an
|
|
6
|
+
anchor is an :class:`Association` (``anchor_assoc``). The desktop
|
|
7
|
+
site-plan client aggregates the three for display, but the server
|
|
8
|
+
keeps them separate.
|
|
9
|
+
|
|
10
|
+
The server's ``create_win_anchor`` schema requires:
|
|
11
|
+
|
|
12
|
+
- ``name`` (string)
|
|
13
|
+
- ``type`` (``ANCHOR`` or ``BRIDGE``; defaults to ``ANCHOR``)
|
|
14
|
+
- ``position`` (object): ``{x, y, z, sublocation_uid}`` (or ``sl_uid``)
|
|
15
|
+
|
|
16
|
+
Optional: ``locked``, ``acc_config``, ``bt_config``, ``state``,
|
|
17
|
+
``device_config``.
|
|
18
|
+
|
|
19
|
+
If your workflow is "I have a MAC and want a placed anchor bound to
|
|
20
|
+
it," that's three calls — anchor create + node create + association
|
|
21
|
+
create. A compound that wraps the saga is planned for a future
|
|
22
|
+
milestone; for now, drive the three primitives in your application.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
30
|
+
from ..models import Anchor
|
|
31
|
+
from ._base import ResourceAPI
|
|
32
|
+
|
|
33
|
+
_VALID_ANCHOR_TYPES: frozenset[str] = frozenset({"ANCHOR", "BRIDGE"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnchorsAPI(ResourceAPI):
|
|
37
|
+
"""List, create, update, and delete WIN anchors in the current scope.
|
|
38
|
+
|
|
39
|
+
Anchors are present only when the WIN module is enabled on the
|
|
40
|
+
server. Some deployments return 404 on the list endpoint — the SDK
|
|
41
|
+
raises :class:`NotFound`.
|
|
42
|
+
|
|
43
|
+
Server-side anchor deletion closes open anchor associations
|
|
44
|
+
automatically; the SDK does not pre-emptively call
|
|
45
|
+
:meth:`AssociationsAPI.close`.
|
|
46
|
+
|
|
47
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def create(
|
|
51
|
+
self,
|
|
52
|
+
*,
|
|
53
|
+
name: str,
|
|
54
|
+
position: dict[str, Any],
|
|
55
|
+
type: str = "ANCHOR",
|
|
56
|
+
**fields: Any,
|
|
57
|
+
) -> Anchor:
|
|
58
|
+
"""Create a placed anchor (WIN beacon).
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
name
|
|
63
|
+
Display name. Required.
|
|
64
|
+
position
|
|
65
|
+
Placement object. Must include ``x``, ``y``, ``z`` numbers
|
|
66
|
+
and either ``sublocation_uid`` or ``sl_uid``. The server's
|
|
67
|
+
Joi schema accepts both alias names.
|
|
68
|
+
type
|
|
69
|
+
``"ANCHOR"`` (default) or ``"BRIDGE"``. Other values are
|
|
70
|
+
rejected with ``ValueError`` before any HTTP call.
|
|
71
|
+
**fields
|
|
72
|
+
Optional pass-through fields the server schema permits:
|
|
73
|
+
``locked`` (bool), ``acc_config`` / ``bt_config`` /
|
|
74
|
+
``state`` (dicts), ``device_config`` (dict with
|
|
75
|
+
``battery``, ``model``, ``pwr_profile``, plus arbitrary
|
|
76
|
+
keys up to 64 bytes).
|
|
77
|
+
|
|
78
|
+
Examples
|
|
79
|
+
--------
|
|
80
|
+
>>> anchor = client.anchors.create(
|
|
81
|
+
... name="Anchor 7",
|
|
82
|
+
... position={"x": 1.0, "y": 2.0, "z": 0.0, "sublocation_uid": area.uid},
|
|
83
|
+
... )
|
|
84
|
+
>>> anchor.uid
|
|
85
|
+
'NELkj…'
|
|
86
|
+
|
|
87
|
+
Notes
|
|
88
|
+
-----
|
|
89
|
+
``win_anchor`` does NOT carry a ``mac_address`` field. If you
|
|
90
|
+
want to bind a piece of hardware (with a MAC) to this anchor,
|
|
91
|
+
also call :meth:`NodesAPI.create` and
|
|
92
|
+
:meth:`AssociationsAPI.create` (or use the planned
|
|
93
|
+
``anchors.create_with_node`` compound when it lands).
|
|
94
|
+
"""
|
|
95
|
+
if type not in _VALID_ANCHOR_TYPES:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"anchor type must be one of {sorted(_VALID_ANCHOR_TYPES)}; got {type!r}"
|
|
98
|
+
)
|
|
99
|
+
body: dict[str, Any] = {
|
|
100
|
+
"name": name,
|
|
101
|
+
"type": type,
|
|
102
|
+
"position": position,
|
|
103
|
+
**fields,
|
|
104
|
+
}
|
|
105
|
+
response = self._post("/api/v2/win_anchors/", json=body)
|
|
106
|
+
payload = response.json()
|
|
107
|
+
try:
|
|
108
|
+
item = unwrap_resource_single(payload, "win_anchor")
|
|
109
|
+
except ValueError:
|
|
110
|
+
item = payload if isinstance(payload, dict) else {}
|
|
111
|
+
return Anchor.from_wire(item)
|
|
112
|
+
|
|
113
|
+
def update(self, uid: str, **fields: Any) -> Anchor:
|
|
114
|
+
"""Update an anchor's fields.
|
|
115
|
+
|
|
116
|
+
Free-form ``**fields`` to match the server's permissive
|
|
117
|
+
``update_win_anchor`` schema. Validates ``type`` when present
|
|
118
|
+
(must be ``ANCHOR`` or ``BRIDGE``). Does NOT validate
|
|
119
|
+
``position`` structure — caller is responsible for shape.
|
|
120
|
+
"""
|
|
121
|
+
if "type" in fields and fields["type"] not in _VALID_ANCHOR_TYPES:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"anchor type must be one of {sorted(_VALID_ANCHOR_TYPES)}; got {fields['type']!r}"
|
|
124
|
+
)
|
|
125
|
+
response = self._put(f"/api/v2/win_anchors/{uid}", json=fields)
|
|
126
|
+
payload = response.json()
|
|
127
|
+
try:
|
|
128
|
+
item = unwrap_resource_single(payload, "win_anchor")
|
|
129
|
+
except ValueError:
|
|
130
|
+
item = payload if isinstance(payload, dict) else {}
|
|
131
|
+
return Anchor.from_wire(item)
|
|
132
|
+
|
|
133
|
+
def delete(self, uid: str) -> None:
|
|
134
|
+
"""Delete an anchor.
|
|
135
|
+
|
|
136
|
+
Server-side closes any open anchor associations referencing it;
|
|
137
|
+
the SDK does NOT pre-emptively call ``associations.close``.
|
|
138
|
+
"""
|
|
139
|
+
self._delete(f"/api/v2/win_anchors/{uid}")
|
|
140
|
+
|
|
141
|
+
def list(self) -> list[Anchor]:
|
|
142
|
+
response = self._get("/api/v2/win_anchors/")
|
|
143
|
+
items = unwrap_resource(response.json(), "win_anchors")
|
|
144
|
+
return [Anchor.from_wire(item) for item in items]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
__all__ = ["AnchorsAPI"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Areas (sublocations) 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 Area
|
|
9
|
+
from ._base import ResourceAPI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AreasAPI(ResourceAPI):
|
|
13
|
+
"""List, create, update, and delete areas (sublocations) in the current scope.
|
|
14
|
+
|
|
15
|
+
The wire-format name is ``sublocations``; SDK exposes them as
|
|
16
|
+
``areas`` to match operator terminology.
|
|
17
|
+
|
|
18
|
+
Cascade contract: ``areas.delete(uid)`` removes the area and every
|
|
19
|
+
floorplan, anchor, and zone bound to it (server-side cascade). The
|
|
20
|
+
SDK does not pre-emptively delete children.
|
|
21
|
+
|
|
22
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def create(self, name: str, *, location_uid: str, **fields: Any) -> Area:
|
|
26
|
+
"""Create an area inside ``location_uid``.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
name
|
|
31
|
+
Display name.
|
|
32
|
+
location_uid
|
|
33
|
+
Parent site uid. Required by the server.
|
|
34
|
+
**fields
|
|
35
|
+
Additional optional placement metadata: ``description``,
|
|
36
|
+
``position_x``, ``position_y``, ``width``, ``height``,
|
|
37
|
+
``rotation``.
|
|
38
|
+
|
|
39
|
+
Examples
|
|
40
|
+
--------
|
|
41
|
+
>>> area = client.areas.create(name="Bay 3", location_uid=site.uid)
|
|
42
|
+
"""
|
|
43
|
+
body: dict[str, Any] = {"name": name, "location_uid": location_uid, **fields}
|
|
44
|
+
response = self._post("/api/v2/sublocations/", json=body)
|
|
45
|
+
payload = response.json()
|
|
46
|
+
try:
|
|
47
|
+
item = unwrap_resource_single(payload, "sublocation")
|
|
48
|
+
except ValueError:
|
|
49
|
+
item = payload if isinstance(payload, dict) else {}
|
|
50
|
+
return Area.from_wire(item)
|
|
51
|
+
|
|
52
|
+
def update(self, uid: str, **fields: Any) -> Area:
|
|
53
|
+
"""Update an area's fields.
|
|
54
|
+
|
|
55
|
+
The body is the kwargs verbatim — the SDK does not enforce an
|
|
56
|
+
allowlist on area updates, mirroring the desktop site-plan
|
|
57
|
+
client's ``updateItem`` flow.
|
|
58
|
+
"""
|
|
59
|
+
response = self._put(f"/api/v2/sublocations/{uid}", json=fields)
|
|
60
|
+
payload = response.json()
|
|
61
|
+
try:
|
|
62
|
+
item = unwrap_resource_single(payload, "sublocation")
|
|
63
|
+
except ValueError:
|
|
64
|
+
item = payload if isinstance(payload, dict) else {}
|
|
65
|
+
return Area.from_wire(item)
|
|
66
|
+
|
|
67
|
+
def delete(self, uid: str) -> None:
|
|
68
|
+
"""Delete an area (cascades to its floorplans / anchors / zones server-side)."""
|
|
69
|
+
self._delete(f"/api/v2/sublocations/{uid}")
|
|
70
|
+
|
|
71
|
+
def list(self) -> list[Area]:
|
|
72
|
+
"""Return all areas visible to the current scope."""
|
|
73
|
+
response = self._get("/api/v2/sublocations/")
|
|
74
|
+
items = unwrap_resource(response.json(), "sublocations")
|
|
75
|
+
return [Area.from_wire(item) for item in items]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["AreasAPI"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Trackable-association resource sub-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
8
|
+
from .._query import normalize_mac
|
|
9
|
+
from .._time import to_epoch_seconds
|
|
10
|
+
from ..errors import RtlsError
|
|
11
|
+
from ..models import Association, BulkFailure, BulkResult
|
|
12
|
+
from ._base import ResourceAPI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AssociationsAPI(ResourceAPI):
|
|
16
|
+
"""Read trackable ↔ MAC associations.
|
|
17
|
+
|
|
18
|
+
Two endpoints surface here:
|
|
19
|
+
|
|
20
|
+
- :meth:`list` reads currently-open associations.
|
|
21
|
+
- :meth:`list_active_in_period` reads associations active in a window;
|
|
22
|
+
uses a distinct endpoint path (``/track_assoc``) and — uniquely
|
|
23
|
+
among RTLS endpoints — accepts ``start`` / ``end`` as **epoch
|
|
24
|
+
seconds**, not milliseconds (RESEARCH §4).
|
|
25
|
+
|
|
26
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def list_active_in_period(self, start: datetime, end: datetime) -> list[Association]:
|
|
30
|
+
"""Return associations active during ``[start, end]``.
|
|
31
|
+
|
|
32
|
+
The SDK converts ``datetime`` to epoch seconds for this endpoint.
|
|
33
|
+
Pass timezone-aware datetimes; naive values raise ``ValueError``.
|
|
34
|
+
"""
|
|
35
|
+
params = {
|
|
36
|
+
"start": to_epoch_seconds(start, arg_name="start"),
|
|
37
|
+
"end": to_epoch_seconds(end, arg_name="end"),
|
|
38
|
+
}
|
|
39
|
+
response = self._get("/api/v2/track_assoc", params=params)
|
|
40
|
+
items = unwrap_resource(response.json(), "trackable_objects_associations")
|
|
41
|
+
return [Association.from_wire(item) for item in items]
|
|
42
|
+
|
|
43
|
+
def create(self, mac_address: str, trackable_uid: str) -> Association:
|
|
44
|
+
"""Create a trackable-association (bind a MAC to a tag).
|
|
45
|
+
|
|
46
|
+
The MAC is normalized to bare 12-hex lowercase before sending
|
|
47
|
+
(server validates ``/^[a-fA-F0-9]{12}$/``).
|
|
48
|
+
|
|
49
|
+
Note: this binds a node (by MAC) to a TAG (`obj_uid`). To bind a
|
|
50
|
+
node to a placed ANCHOR (`win_uid`), the wire endpoint is
|
|
51
|
+
``/win_anchor_associations`` — that resource is not yet exposed
|
|
52
|
+
on the SDK (planned for a future milestone alongside
|
|
53
|
+
``anchors.create_with_node``).
|
|
54
|
+
"""
|
|
55
|
+
body = {"mac_address": normalize_mac(mac_address), "obj_uid": trackable_uid}
|
|
56
|
+
response = self._post("/api/v2/trackable_objects_associations/", json=body)
|
|
57
|
+
payload = response.json()
|
|
58
|
+
# Create returns the singular envelope {association: ...}; the SDK
|
|
59
|
+
# tolerates both shapes.
|
|
60
|
+
try:
|
|
61
|
+
item = unwrap_resource_single(payload, "association")
|
|
62
|
+
except ValueError:
|
|
63
|
+
item = payload if isinstance(payload, dict) else {}
|
|
64
|
+
return Association.from_wire(item)
|
|
65
|
+
|
|
66
|
+
def close(
|
|
67
|
+
self,
|
|
68
|
+
association: Association | str | None = None,
|
|
69
|
+
*,
|
|
70
|
+
mac_address: str | None = None,
|
|
71
|
+
trackable_uid: str | None = None,
|
|
72
|
+
) -> Association:
|
|
73
|
+
"""Close an open association.
|
|
74
|
+
|
|
75
|
+
The endpoint is ``PUT /trackable_objects_associations/close`` and
|
|
76
|
+
the server identifies the association by ``mac_address`` OR
|
|
77
|
+
``obj_uid`` (the trackable's uid) in the body — **not** by the
|
|
78
|
+
association's own uid (the server's close-handler reads only
|
|
79
|
+
``req.body.mac_address`` and ``req.body.obj_uid``). M14 regression
|
|
80
|
+
— earlier ``close(uid)`` sent ``{"uid": "..."}`` and the server
|
|
81
|
+
replied ``"No mac or trackable object uid specified."``.
|
|
82
|
+
|
|
83
|
+
Three accepted call shapes:
|
|
84
|
+
|
|
85
|
+
- ``close(association)`` — pass the :class:`Association` object
|
|
86
|
+
returned by :meth:`create` / :meth:`list`; the SDK picks
|
|
87
|
+
``mac_address`` or ``obj_uid`` from it.
|
|
88
|
+
- ``close(association_uid)`` — pass the association's own uid;
|
|
89
|
+
the SDK does one extra ``list()`` to resolve the identifying
|
|
90
|
+
fields.
|
|
91
|
+
- ``close(mac_address=..., trackable_uid=...)`` — pass either
|
|
92
|
+
identifier explicitly.
|
|
93
|
+
"""
|
|
94
|
+
if isinstance(association, Association):
|
|
95
|
+
mac_address = mac_address or association.mac_address
|
|
96
|
+
trackable_uid = trackable_uid or association.obj_uid
|
|
97
|
+
elif isinstance(association, str):
|
|
98
|
+
# Look up by association uid (extra round-trip — callers can
|
|
99
|
+
# avoid this by passing the Association object directly).
|
|
100
|
+
for existing in self.list():
|
|
101
|
+
if existing.uid == association:
|
|
102
|
+
mac_address = mac_address or existing.mac_address
|
|
103
|
+
trackable_uid = trackable_uid or existing.obj_uid
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
body: dict[str, str] = {}
|
|
107
|
+
if mac_address:
|
|
108
|
+
body["mac_address"] = mac_address
|
|
109
|
+
if trackable_uid:
|
|
110
|
+
body["obj_uid"] = trackable_uid
|
|
111
|
+
if not body:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"associations.close: provide an Association, an association uid, "
|
|
114
|
+
"or mac_address / trackable_uid kwargs"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
response = self._put("/api/v2/trackable_objects_associations/close", json=body)
|
|
118
|
+
payload = response.json()
|
|
119
|
+
try:
|
|
120
|
+
item = unwrap_resource_single(payload, "association")
|
|
121
|
+
except ValueError:
|
|
122
|
+
item = payload if isinstance(payload, dict) else {}
|
|
123
|
+
return Association.from_wire(item)
|
|
124
|
+
|
|
125
|
+
def bulk_create(self, pairs: list[tuple[str, str]]) -> BulkResult[Association]:
|
|
126
|
+
"""Create many associations, one at a time, continue-on-error.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
pairs
|
|
131
|
+
List of ``(mac_address, trackable_uid)`` tuples.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
BulkResult[Association]
|
|
136
|
+
``succeeded`` lists the created associations; ``failures``
|
|
137
|
+
carries per-row errors.
|
|
138
|
+
"""
|
|
139
|
+
result: BulkResult[Association] = BulkResult()
|
|
140
|
+
for mac, tag_uid in pairs:
|
|
141
|
+
try:
|
|
142
|
+
result.succeeded.append(self.create(mac, tag_uid))
|
|
143
|
+
except RtlsError as exc:
|
|
144
|
+
result.failures.append(
|
|
145
|
+
BulkFailure(error=exc, input={"mac_address": mac, "obj_uid": tag_uid})
|
|
146
|
+
)
|
|
147
|
+
# ValueError from MAC normalization (malformed input) is a
|
|
148
|
+
# programmer error — propagates out and aborts the bulk.
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
def list(self) -> list[Association]:
|
|
152
|
+
response = self._get("/api/v2/trackable_objects_associations/")
|
|
153
|
+
items = unwrap_resource(response.json(), "trackable_objects_associations")
|
|
154
|
+
return [Association.from_wire(item) for item in items]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["AssociationsAPI"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Auth / capabilities resource sub-client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..compounds import auth as _compound
|
|
6
|
+
from ..errors import RtlsError
|
|
7
|
+
from ..models import Capabilities, User
|
|
8
|
+
from ._base import ResourceAPI
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AuthAPI(ResourceAPI):
|
|
12
|
+
"""User-facing auth helpers — capabilities probe and ``whoami``.
|
|
13
|
+
|
|
14
|
+
Note that the active SDK token is *not* exposed here. Mutating
|
|
15
|
+
operations (login, logout, change-password) land in M5.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def whoami(self) -> User:
|
|
19
|
+
"""Return the currently-authenticated user.
|
|
20
|
+
|
|
21
|
+
Delegates to :meth:`UsersAPI.me` — composed via a search of
|
|
22
|
+
``GET /users?withProjects=true`` for the row matching the SDK's
|
|
23
|
+
stored auth email. See ``UsersAPI.me`` for details.
|
|
24
|
+
"""
|
|
25
|
+
return self._client.users.me()
|
|
26
|
+
|
|
27
|
+
def capabilities(self) -> Capabilities:
|
|
28
|
+
"""Probe whether SSO is configured + infer the API version.
|
|
29
|
+
|
|
30
|
+
Calls ``GET /auth/sso/log_in``. A successful response means SSO is
|
|
31
|
+
configured (``sso=True``, ``api_version="2.1"``); any
|
|
32
|
+
:class:`RtlsError` from the call means SSO is unavailable
|
|
33
|
+
(``sso=False``, ``api_version="2.0"``). This mirrors the JS
|
|
34
|
+
reference client's "rejection-is-the-signal" heuristic
|
|
35
|
+
(RESEARCH §4 "Probe SSO support").
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
self._get("/api/v2/auth/sso/log_in")
|
|
39
|
+
except RtlsError:
|
|
40
|
+
return Capabilities(sso=False, api_version="2.0")
|
|
41
|
+
return Capabilities(sso=True, api_version="2.1")
|
|
42
|
+
|
|
43
|
+
def change_password(self, old_password: str, new_password: str) -> None:
|
|
44
|
+
"""Change the current user's password and re-authenticate the SDK.
|
|
45
|
+
|
|
46
|
+
Four steps: validate the old password, validate the new
|
|
47
|
+
password, ``PUT /users/changePassword``, then clear the cached
|
|
48
|
+
token and force a fresh login with the new credentials. After a
|
|
49
|
+
successful run, the SDK's stored password is the new one.
|
|
50
|
+
|
|
51
|
+
Step names: ``validate_old``, ``validate_new``,
|
|
52
|
+
``change_password``, ``reauthenticate``.
|
|
53
|
+
|
|
54
|
+
If the re-authentication step fails (server accepted the change
|
|
55
|
+
but the new login can't proceed), the SDK raises
|
|
56
|
+
:class:`AuthenticationError` with a message explaining that the
|
|
57
|
+
in-memory state is now stale. The caller should reconstruct the
|
|
58
|
+
client with the new password.
|
|
59
|
+
"""
|
|
60
|
+
_compound.change_password(self._client, old_password, new_password)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["AuthAPI"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Companies 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 Company
|
|
9
|
+
from ._base import ResourceAPI
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CompaniesAPI(ResourceAPI):
|
|
13
|
+
"""List, create, update, and delete companies.
|
|
14
|
+
|
|
15
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def create(
|
|
19
|
+
self,
|
|
20
|
+
name: str,
|
|
21
|
+
*,
|
|
22
|
+
admin_email: str,
|
|
23
|
+
admin_password: str,
|
|
24
|
+
admin_role: str = "company_admin",
|
|
25
|
+
**company_fields: Any,
|
|
26
|
+
) -> Company:
|
|
27
|
+
"""Create a new company and seed it with an admin user.
|
|
28
|
+
|
|
29
|
+
Per RESEARCH §1, ``POST /companies/`` accepts a body like
|
|
30
|
+
``{company: {...}, user: {email, password, role}}`` — the SDK
|
|
31
|
+
normalises that envelope shape so callers don't have to.
|
|
32
|
+
|
|
33
|
+
The seeded admin's ``role`` is server-required (Joi
|
|
34
|
+
``companyCreate.user.role``). It defaults to ``"company_admin"``,
|
|
35
|
+
the only role that makes sense for a freshly-seeded tenant.
|
|
36
|
+
"""
|
|
37
|
+
body = {
|
|
38
|
+
"company": {"name": name, **company_fields},
|
|
39
|
+
"user": {
|
|
40
|
+
"email": admin_email,
|
|
41
|
+
"password": admin_password,
|
|
42
|
+
"role": admin_role,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
response = self._post("/api/v2/companies/", json=body)
|
|
46
|
+
payload = response.json()
|
|
47
|
+
try:
|
|
48
|
+
item = unwrap_resource_single(payload, "company")
|
|
49
|
+
except ValueError:
|
|
50
|
+
item = payload if isinstance(payload, dict) else {}
|
|
51
|
+
return Company.from_wire(item)
|
|
52
|
+
|
|
53
|
+
def update(self, uid: str, **fields: Any) -> Company:
|
|
54
|
+
"""Update a company's fields."""
|
|
55
|
+
body = {"company": fields}
|
|
56
|
+
response = self._put(f"/api/v2/companies/{uid}", json=body)
|
|
57
|
+
payload = response.json()
|
|
58
|
+
try:
|
|
59
|
+
item = unwrap_resource_single(payload, "company")
|
|
60
|
+
except ValueError:
|
|
61
|
+
item = payload if isinstance(payload, dict) else {}
|
|
62
|
+
return Company.from_wire(item)
|
|
63
|
+
|
|
64
|
+
def delete(self, uid: str) -> None:
|
|
65
|
+
"""Delete a company."""
|
|
66
|
+
self._delete(f"/api/v2/companies/{uid}")
|
|
67
|
+
|
|
68
|
+
def list(self) -> list[Company]:
|
|
69
|
+
"""Return companies visible to the current user.
|
|
70
|
+
|
|
71
|
+
On most deployments this returns exactly one company; super-admin
|
|
72
|
+
users may see all companies.
|
|
73
|
+
"""
|
|
74
|
+
response = self._get("/api/v2/companies/")
|
|
75
|
+
items = unwrap_resource(response.json(), "companies")
|
|
76
|
+
return [Company.from_wire(item) for item in items]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["CompaniesAPI"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Session-bootstrap sub-client (M6).
|
|
2
|
+
|
|
3
|
+
Surfaces :meth:`load` — the mega-method that hydrates the full
|
|
4
|
+
deployment view under the active project scope. Delegates to
|
|
5
|
+
:mod:`rtls_sdk.compounds.context`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..compounds import context as _compound
|
|
11
|
+
from ..models import SessionContext
|
|
12
|
+
from ._base import ResourceAPI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ContextAPI(ResourceAPI):
|
|
16
|
+
"""Aggregated session bootstrap.
|
|
17
|
+
|
|
18
|
+
The single :meth:`load` method replaces the JS reference client's
|
|
19
|
+
18-call ``loadEntities`` saga (RESEARCH §4) — DESIGN's highest-value
|
|
20
|
+
compound collapse.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def load(self, *, scope: str | None = None) -> SessionContext:
|
|
24
|
+
"""Hydrate the deployment view under the current scope.
|
|
25
|
+
|
|
26
|
+
Sub-resources are fetched sequentially in a stable order. Each
|
|
27
|
+
sub-call's failure goes into :attr:`SessionContext.errors`
|
|
28
|
+
keyed by a stable name; the rest of the snapshot still
|
|
29
|
+
populates. :attr:`SessionContext.partial` is ``True`` when any
|
|
30
|
+
sub-call failed.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
scope
|
|
35
|
+
``None`` or ``"project"`` (the default and only supported
|
|
36
|
+
value in v1). ``"company"`` and ``"system"`` raise
|
|
37
|
+
:class:`NotImplementedError`.
|
|
38
|
+
|
|
39
|
+
Examples
|
|
40
|
+
--------
|
|
41
|
+
>>> ctx = client.context.load()
|
|
42
|
+
>>> ctx.tags, ctx.zones, ctx.sites
|
|
43
|
+
>>> if ctx.partial:
|
|
44
|
+
... for name, exc in ctx.errors.items():
|
|
45
|
+
... print(f"{name}: {exc}")
|
|
46
|
+
"""
|
|
47
|
+
return _compound.collect_session_context(self._client, scope=scope)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["ContextAPI"]
|