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,149 @@
|
|
|
1
|
+
"""Events resource sub-client.
|
|
2
|
+
|
|
3
|
+
M2 ships a single-page reader for zone events; M7 adds the lazy
|
|
4
|
+
iterator surface (``iter_pws`` / ``pages``) for PWS / external_events
|
|
5
|
+
via the body-URL paginator.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .._envelope import unwrap_resource
|
|
15
|
+
from .._pagination import BodyUrlPaginator, Page
|
|
16
|
+
from .._query import pack_array_param
|
|
17
|
+
from .._time import to_epoch_ms
|
|
18
|
+
from ..models import PwsEvent, ZoneEvent
|
|
19
|
+
from ._base import ResourceAPI
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EventsAPI(ResourceAPI):
|
|
23
|
+
"""Read zone events and external (PWS) events.
|
|
24
|
+
|
|
25
|
+
``iter_pws`` and ``pages`` are lazy: HTTP requests fire only when
|
|
26
|
+
the iterator advances past the current page. This keeps large
|
|
27
|
+
windows out of memory and lets callers ``break`` early.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def list_zone_events(
|
|
31
|
+
self,
|
|
32
|
+
start: datetime,
|
|
33
|
+
end: datetime,
|
|
34
|
+
*,
|
|
35
|
+
area_uids: list[str],
|
|
36
|
+
type: str = "information",
|
|
37
|
+
) -> list[ZoneEvent]:
|
|
38
|
+
"""Return zone events for ``area_uids`` in ``[start, end]``.
|
|
39
|
+
|
|
40
|
+
The server returns a paginated response; this method reads only
|
|
41
|
+
the first page. Callers needing the full set should use the
|
|
42
|
+
iterator surface.
|
|
43
|
+
"""
|
|
44
|
+
params: list[tuple[str, str]] = [
|
|
45
|
+
("from", str(to_epoch_ms(start, arg_name="start"))),
|
|
46
|
+
("to", str(to_epoch_ms(end, arg_name="end"))),
|
|
47
|
+
("type", type),
|
|
48
|
+
("format", "json"),
|
|
49
|
+
]
|
|
50
|
+
params.extend(pack_array_param("area_uid", area_uids))
|
|
51
|
+
response = self._get("/api/v2/data/zone_events", params=params)
|
|
52
|
+
payload = response.json()
|
|
53
|
+
if isinstance(payload, dict):
|
|
54
|
+
for key in ("zone_events", "events", "data"):
|
|
55
|
+
if key in payload and isinstance(payload[key], list):
|
|
56
|
+
return [ZoneEvent.from_wire(it) for it in payload[key]]
|
|
57
|
+
items = unwrap_resource(payload, "zone_events")
|
|
58
|
+
return [ZoneEvent.from_wire(item) for item in items]
|
|
59
|
+
|
|
60
|
+
def iter_pws(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
trackable_uid: str | list[str],
|
|
64
|
+
start: datetime,
|
|
65
|
+
end: datetime,
|
|
66
|
+
page_size: int = 1000,
|
|
67
|
+
) -> Iterator[PwsEvent]:
|
|
68
|
+
"""Lazy iterator over PWS events for one or more trackables.
|
|
69
|
+
|
|
70
|
+
Yields one :class:`PwsEvent` per server row. HTTP requests fire
|
|
71
|
+
only when the iterator advances past the current page — calling
|
|
72
|
+
``list(client.events.iter_pws(...))`` materialises everything,
|
|
73
|
+
while ``next(iter)`` fetches the minimum needed.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
trackable_uid
|
|
78
|
+
One uid string or a list of uids (Rails-style
|
|
79
|
+
``track_uid[]=`` query params).
|
|
80
|
+
start, end
|
|
81
|
+
Tz-aware datetimes. Naive datetimes raise ``ValueError``.
|
|
82
|
+
page_size
|
|
83
|
+
Server-side limit hint. The server may return fewer rows.
|
|
84
|
+
|
|
85
|
+
Examples
|
|
86
|
+
--------
|
|
87
|
+
>>> for event in client.events.iter_pws(
|
|
88
|
+
... trackable_uid="t-1", start=start, end=end
|
|
89
|
+
... ):
|
|
90
|
+
... print(event.ts, event.zone_uid)
|
|
91
|
+
"""
|
|
92
|
+
return iter(self._build_pws_paginator(trackable_uid, start, end, page_size))
|
|
93
|
+
|
|
94
|
+
def pages(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
trackable_uid: str | list[str],
|
|
98
|
+
start: datetime,
|
|
99
|
+
end: datetime,
|
|
100
|
+
page_size: int = 1000,
|
|
101
|
+
) -> Iterator[Page[PwsEvent]]:
|
|
102
|
+
"""Iterate one :class:`Page` at a time — for progress reporting.
|
|
103
|
+
|
|
104
|
+
Same conventions as :meth:`iter_pws`, but yields whole pages
|
|
105
|
+
carrying ``page_number`` / ``total_count`` / ``has_next`` so
|
|
106
|
+
callers can print progress without counting items themselves.
|
|
107
|
+
"""
|
|
108
|
+
return self._build_pws_paginator(trackable_uid, start, end, page_size).pages()
|
|
109
|
+
|
|
110
|
+
def _build_pws_paginator(
|
|
111
|
+
self,
|
|
112
|
+
trackable_uid: str | list[str],
|
|
113
|
+
start: datetime,
|
|
114
|
+
end: datetime,
|
|
115
|
+
page_size: int,
|
|
116
|
+
) -> BodyUrlPaginator[PwsEvent]:
|
|
117
|
+
uids = [trackable_uid] if isinstance(trackable_uid, str) else list(trackable_uid)
|
|
118
|
+
params: list[tuple[str, str]] = [
|
|
119
|
+
("event_type[]", "pws"),
|
|
120
|
+
("from", str(to_epoch_ms(start, arg_name="start"))),
|
|
121
|
+
("to", str(to_epoch_ms(end, arg_name="end"))),
|
|
122
|
+
("limit", str(page_size)),
|
|
123
|
+
]
|
|
124
|
+
params.extend(pack_array_param("track_uid", uids))
|
|
125
|
+
|
|
126
|
+
def parse(raw: Any) -> list[PwsEvent]:
|
|
127
|
+
if not isinstance(raw, list):
|
|
128
|
+
return []
|
|
129
|
+
out: list[PwsEvent] = []
|
|
130
|
+
for item in raw:
|
|
131
|
+
if isinstance(item, dict):
|
|
132
|
+
out.append(PwsEvent.from_wire(item))
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
# Bind the client's request method as the fetcher. Wraps in
|
|
136
|
+
# check_response via self._client._request semantics.
|
|
137
|
+
def fetch(path: str, *, params: Any = None) -> Any:
|
|
138
|
+
return self._client._request("GET", path, params=params)
|
|
139
|
+
|
|
140
|
+
return BodyUrlPaginator[PwsEvent](
|
|
141
|
+
fetch=fetch,
|
|
142
|
+
path="/api/v2/external_events",
|
|
143
|
+
params=params,
|
|
144
|
+
parse_items=parse,
|
|
145
|
+
list_key="external_events",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["EventsAPI"]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Floorplans resource sub-client.
|
|
2
|
+
|
|
3
|
+
Full CRUD plus image upload (base64-in-JSON on the same POST body) and
|
|
4
|
+
image download. Live-server schema (probed M10) requires:
|
|
5
|
+
|
|
6
|
+
- ``POST /api/v2/floorplans/`` — ``sublocation_uid`` and ``image:
|
|
7
|
+
{original_filename, file}`` are required; ``display_name``,
|
|
8
|
+
``image_scale``, ``image_rotation``, ``image_offset_x``,
|
|
9
|
+
``image_offset_y`` are optional. **There is no `name` field, and
|
|
10
|
+
imageless floorplans are NOT permitted.**
|
|
11
|
+
- ``PUT /api/v2/floorplans/{uid}`` — accepts any subset of the above
|
|
12
|
+
fields. Image replacement is optional.
|
|
13
|
+
- ``GET /api/v2/floorplans/{uid}`` — returns ``{floorplans: [<item>]}``
|
|
14
|
+
(single-item list envelope).
|
|
15
|
+
- The created floorplan's ``url`` is a relative path of the form
|
|
16
|
+
``/uploads/floorplan/image/<uid>/<original_filename>`` and is
|
|
17
|
+
fetched via a separate authenticated GET with a 180-second read
|
|
18
|
+
timeout (production floorplans can be tens of MB of PDF).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import IO, Any
|
|
25
|
+
from urllib.parse import urljoin
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from .._envelope import unwrap_resource, unwrap_resource_single
|
|
30
|
+
from .._query import encode_image_payload
|
|
31
|
+
from ..models import Floorplan
|
|
32
|
+
from ._base import ResourceAPI
|
|
33
|
+
|
|
34
|
+
# Matches the desktop site-plan client's ``Network::Timeout::Resource``
|
|
35
|
+
# of 180 s. Used only for image GETs — chunked PDFs can be tens of MB.
|
|
36
|
+
_IMAGE_DOWNLOAD_TIMEOUT_SEC: float = 180.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FloorplansAPI(ResourceAPI):
|
|
40
|
+
"""List, create, update, delete floorplans; upload + download images.
|
|
41
|
+
|
|
42
|
+
``list`` is defined last (class-scope shadow workaround).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def get(self, uid: str) -> Floorplan:
|
|
46
|
+
"""Fetch one floorplan by uid.
|
|
47
|
+
|
|
48
|
+
Wire format: server returns ``{floorplans: [<item>]}`` even for
|
|
49
|
+
a single-uid GET. The SDK unwraps the list.
|
|
50
|
+
"""
|
|
51
|
+
response = self._get(f"/api/v2/floorplans/{uid}")
|
|
52
|
+
payload = response.json()
|
|
53
|
+
if isinstance(payload, dict) and "floorplans" in payload:
|
|
54
|
+
items = payload["floorplans"]
|
|
55
|
+
if isinstance(items, list) and items:
|
|
56
|
+
return Floorplan.from_wire(items[0])
|
|
57
|
+
if isinstance(payload, dict):
|
|
58
|
+
try:
|
|
59
|
+
return Floorplan.from_wire(unwrap_resource_single(payload, "floorplan"))
|
|
60
|
+
except ValueError:
|
|
61
|
+
return Floorplan.from_wire(payload)
|
|
62
|
+
raise ValueError(f"unexpected floorplan payload shape: {type(payload).__name__}")
|
|
63
|
+
|
|
64
|
+
def create(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
sublocation_uid: str,
|
|
68
|
+
image_bytes: bytes | None = None,
|
|
69
|
+
original_filename: str | None = None,
|
|
70
|
+
image_path: str | Path | None = None,
|
|
71
|
+
display_name: str | None = None,
|
|
72
|
+
image_scale: float = 1.0,
|
|
73
|
+
image_rotation: float = 0.0,
|
|
74
|
+
image_offset_x: float = 0.0,
|
|
75
|
+
image_offset_y: float = 0.0,
|
|
76
|
+
**fields: Any,
|
|
77
|
+
) -> Floorplan:
|
|
78
|
+
"""Create a floorplan attached to ``sublocation_uid``.
|
|
79
|
+
|
|
80
|
+
Image input is required and either-or:
|
|
81
|
+
|
|
82
|
+
- Pass ``image_bytes`` (``bytes``) + ``original_filename`` (``str``), OR
|
|
83
|
+
- Pass ``image_path`` (``str | Path``) — the SDK reads the file
|
|
84
|
+
and derives ``original_filename`` from ``Path(image_path).name``.
|
|
85
|
+
|
|
86
|
+
Passing both raises ``ValueError``. Passing neither raises
|
|
87
|
+
``ValueError`` — the server schema requires an image.
|
|
88
|
+
|
|
89
|
+
Parameters
|
|
90
|
+
----------
|
|
91
|
+
sublocation_uid
|
|
92
|
+
Parent area uid. Required.
|
|
93
|
+
image_bytes
|
|
94
|
+
Raw image bytes (PNG / SVG / PDF). Must be valid for its
|
|
95
|
+
format — the server runs ``image-size`` on the bytes and
|
|
96
|
+
500s on a malformed file.
|
|
97
|
+
original_filename
|
|
98
|
+
File name as the server should store it. Required when
|
|
99
|
+
``image_bytes`` is passed.
|
|
100
|
+
image_path
|
|
101
|
+
Convenience: a path on disk. Mutually exclusive with
|
|
102
|
+
``image_bytes`` / ``original_filename``.
|
|
103
|
+
display_name
|
|
104
|
+
Optional user-facing label (the server's ``display_name``
|
|
105
|
+
field — note ``display_name``, not ``name``).
|
|
106
|
+
image_scale, image_rotation, image_offset_x, image_offset_y
|
|
107
|
+
Placement transform. Identity defaults.
|
|
108
|
+
**fields
|
|
109
|
+
Other server-accepted fields (deployment-specific).
|
|
110
|
+
|
|
111
|
+
Examples
|
|
112
|
+
--------
|
|
113
|
+
From in-memory bytes:
|
|
114
|
+
|
|
115
|
+
>>> with open("warehouse.png", "rb") as fh:
|
|
116
|
+
... plan = client.floorplans.create(
|
|
117
|
+
... sublocation_uid=area.uid,
|
|
118
|
+
... image_bytes=fh.read(),
|
|
119
|
+
... original_filename="warehouse.png",
|
|
120
|
+
... )
|
|
121
|
+
|
|
122
|
+
From a path:
|
|
123
|
+
|
|
124
|
+
>>> plan = client.floorplans.create(
|
|
125
|
+
... sublocation_uid=area.uid,
|
|
126
|
+
... image_path="warehouse.png",
|
|
127
|
+
... display_name="Warehouse 12 — Bay 3",
|
|
128
|
+
... )
|
|
129
|
+
"""
|
|
130
|
+
image = self._resolve_image_payload(
|
|
131
|
+
image_bytes=image_bytes,
|
|
132
|
+
original_filename=original_filename,
|
|
133
|
+
image_path=image_path,
|
|
134
|
+
required=True,
|
|
135
|
+
)
|
|
136
|
+
body: dict[str, Any] = {
|
|
137
|
+
"sublocation_uid": sublocation_uid,
|
|
138
|
+
"image": image,
|
|
139
|
+
"image_scale": image_scale,
|
|
140
|
+
"image_rotation": image_rotation,
|
|
141
|
+
"image_offset_x": image_offset_x,
|
|
142
|
+
"image_offset_y": image_offset_y,
|
|
143
|
+
**fields,
|
|
144
|
+
}
|
|
145
|
+
if display_name is not None:
|
|
146
|
+
body["display_name"] = display_name
|
|
147
|
+
response = self._post("/api/v2/floorplans/", json=body)
|
|
148
|
+
return _parse_floorplan(response.json())
|
|
149
|
+
|
|
150
|
+
def update(
|
|
151
|
+
self,
|
|
152
|
+
uid: str,
|
|
153
|
+
*,
|
|
154
|
+
image_bytes: bytes | None = None,
|
|
155
|
+
original_filename: str | None = None,
|
|
156
|
+
image_path: str | Path | None = None,
|
|
157
|
+
**fields: Any,
|
|
158
|
+
) -> Floorplan:
|
|
159
|
+
"""Update a floorplan's fields. Image replacement is optional.
|
|
160
|
+
|
|
161
|
+
Same either-or rule for image inputs as :meth:`create`. Pass
|
|
162
|
+
neither to keep the stored image untouched; pass one to replace
|
|
163
|
+
it.
|
|
164
|
+
"""
|
|
165
|
+
if image_bytes is not None or image_path is not None:
|
|
166
|
+
fields = {
|
|
167
|
+
**fields,
|
|
168
|
+
"image": self._resolve_image_payload(
|
|
169
|
+
image_bytes=image_bytes,
|
|
170
|
+
original_filename=original_filename,
|
|
171
|
+
image_path=image_path,
|
|
172
|
+
required=False,
|
|
173
|
+
),
|
|
174
|
+
}
|
|
175
|
+
response = self._put(f"/api/v2/floorplans/{uid}", json=fields)
|
|
176
|
+
return _parse_floorplan(response.json())
|
|
177
|
+
|
|
178
|
+
def delete(self, uid: str) -> None:
|
|
179
|
+
"""Delete a floorplan (server removes the row AND the stored image)."""
|
|
180
|
+
self._delete(f"/api/v2/floorplans/{uid}")
|
|
181
|
+
|
|
182
|
+
def download_image(
|
|
183
|
+
self,
|
|
184
|
+
floorplan_or_uid: Floorplan | str,
|
|
185
|
+
*,
|
|
186
|
+
stream_to: IO[bytes] | None = None,
|
|
187
|
+
) -> bytes | None:
|
|
188
|
+
"""Download a floorplan's image bytes.
|
|
189
|
+
|
|
190
|
+
Accepts a :class:`Floorplan` (uses its ``.url``) or a uid (the
|
|
191
|
+
SDK first calls :meth:`get` to resolve). The request issues a
|
|
192
|
+
single GET against the server-returned relative URL with a
|
|
193
|
+
180-second read timeout — production floorplans can be tens of
|
|
194
|
+
MB of PDF, well beyond the default 30-second read budget.
|
|
195
|
+
|
|
196
|
+
With ``stream_to=None`` (default), accumulates the full body
|
|
197
|
+
and returns ``bytes``.
|
|
198
|
+
|
|
199
|
+
With ``stream_to=<writable bytes file-like>``, writes chunks
|
|
200
|
+
as they arrive and returns ``None``. Use this for very large
|
|
201
|
+
downloads to keep memory bounded.
|
|
202
|
+
|
|
203
|
+
Examples
|
|
204
|
+
--------
|
|
205
|
+
>>> data = client.floorplans.download_image(plan)
|
|
206
|
+
>>> with open("warehouse.png", "wb") as fh:
|
|
207
|
+
... client.floorplans.download_image(plan, stream_to=fh)
|
|
208
|
+
"""
|
|
209
|
+
if isinstance(floorplan_or_uid, Floorplan):
|
|
210
|
+
plan_url = floorplan_or_uid.url
|
|
211
|
+
else:
|
|
212
|
+
plan_url = self.get(floorplan_or_uid).url
|
|
213
|
+
if not plan_url:
|
|
214
|
+
raise ValueError("floorplan has no image URL (was an image actually uploaded?)")
|
|
215
|
+
|
|
216
|
+
absolute = urljoin(str(self._client._http.base_url), plan_url)
|
|
217
|
+
timeout = httpx.Timeout(
|
|
218
|
+
connect=10.0,
|
|
219
|
+
read=_IMAGE_DOWNLOAD_TIMEOUT_SEC,
|
|
220
|
+
write=10.0,
|
|
221
|
+
pool=5.0,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if stream_to is None:
|
|
225
|
+
response = self._client._http.get(absolute, timeout=timeout)
|
|
226
|
+
response.raise_for_status()
|
|
227
|
+
return response.content
|
|
228
|
+
|
|
229
|
+
with self._client._http.stream("GET", absolute, timeout=timeout) as response:
|
|
230
|
+
response.raise_for_status()
|
|
231
|
+
for chunk in response.iter_bytes(chunk_size=64 * 1024):
|
|
232
|
+
stream_to.write(chunk)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
# -- helpers --------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
def _resolve_image_payload(
|
|
238
|
+
self,
|
|
239
|
+
*,
|
|
240
|
+
image_bytes: bytes | None,
|
|
241
|
+
original_filename: str | None,
|
|
242
|
+
image_path: str | Path | None,
|
|
243
|
+
required: bool,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Resolve the either-or image input into a server-shaped sub-object."""
|
|
246
|
+
if image_bytes is not None and image_path is not None:
|
|
247
|
+
raise ValueError("pass either image_bytes / image_path, not both")
|
|
248
|
+
if image_path is not None:
|
|
249
|
+
path = Path(image_path)
|
|
250
|
+
return encode_image_payload(path.read_bytes(), original_filename or path.name)
|
|
251
|
+
if image_bytes is not None:
|
|
252
|
+
if not original_filename:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
"original_filename is required when image_bytes is passed "
|
|
255
|
+
"(the server uses it to compose the storage path)"
|
|
256
|
+
)
|
|
257
|
+
return encode_image_payload(image_bytes, original_filename)
|
|
258
|
+
if required:
|
|
259
|
+
raise ValueError(
|
|
260
|
+
"image is required on floorplans.create: "
|
|
261
|
+
"pass image_bytes + original_filename, or image_path"
|
|
262
|
+
)
|
|
263
|
+
# Unreachable in practice: callers only enter this method when at
|
|
264
|
+
# least one of the image inputs is set, OR with required=True.
|
|
265
|
+
raise ValueError("no image input supplied")
|
|
266
|
+
|
|
267
|
+
def list(self) -> list[Floorplan]:
|
|
268
|
+
response = self._get("/api/v2/floorplans/")
|
|
269
|
+
items = unwrap_resource(response.json(), "floorplans")
|
|
270
|
+
return [Floorplan.from_wire(item) for item in items]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_floorplan(payload: Any) -> Floorplan:
|
|
274
|
+
"""Server returns bare object on create/update; some deployments envelope."""
|
|
275
|
+
if isinstance(payload, dict):
|
|
276
|
+
try:
|
|
277
|
+
return Floorplan.from_wire(unwrap_resource_single(payload, "floorplan"))
|
|
278
|
+
except ValueError:
|
|
279
|
+
return Floorplan.from_wire(payload)
|
|
280
|
+
raise ValueError(f"unexpected floorplan payload shape: {type(payload).__name__}")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
__all__ = ["FloorplansAPI"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Groups 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 ..compounds import groups as _compound
|
|
9
|
+
from ..models import BulkResult, Group
|
|
10
|
+
from ._base import ResourceAPI
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GroupsAPI(ResourceAPI):
|
|
14
|
+
"""List, create, update, and delete trackable groups.
|
|
15
|
+
|
|
16
|
+
``list`` is defined last (class-scope shadow workaround for mypy).
|
|
17
|
+
M4 adds compound ``delete`` (cascade-detach) and ``add_tags`` /
|
|
18
|
+
``remove_tags`` helpers.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def create(self, name: str, **fields: Any) -> Group:
|
|
22
|
+
"""Create a new group (single REST call)."""
|
|
23
|
+
body: dict[str, Any] = {"name": name, **fields}
|
|
24
|
+
response = self._post("/api/v2/groups", json=body)
|
|
25
|
+
payload = response.json()
|
|
26
|
+
try:
|
|
27
|
+
item = unwrap_resource_single(payload, "group")
|
|
28
|
+
except ValueError:
|
|
29
|
+
item = payload if isinstance(payload, dict) else {}
|
|
30
|
+
return Group.from_wire(item)
|
|
31
|
+
|
|
32
|
+
def update(self, uid: str, **fields: Any) -> Group:
|
|
33
|
+
"""Update a group's metadata (single REST call)."""
|
|
34
|
+
response = self._put(f"/api/v2/groups/{uid}", json=fields)
|
|
35
|
+
payload = response.json()
|
|
36
|
+
try:
|
|
37
|
+
item = unwrap_resource_single(payload, "group")
|
|
38
|
+
except ValueError:
|
|
39
|
+
item = payload if isinstance(payload, dict) else {}
|
|
40
|
+
return Group.from_wire(item)
|
|
41
|
+
|
|
42
|
+
def delete_raw(self, uid: str) -> None:
|
|
43
|
+
"""Delete a group (single REST call, no trackable-detach).
|
|
44
|
+
|
|
45
|
+
**Advanced.** :meth:`delete` (added in M4) first removes the
|
|
46
|
+
group from every trackable that referenced it. Calling
|
|
47
|
+
``delete_raw`` on a group with members leaves dangling references
|
|
48
|
+
— only use it if you've already detached or know the server
|
|
49
|
+
cascades server-side.
|
|
50
|
+
"""
|
|
51
|
+
self._delete(f"/api/v2/groups/{uid}")
|
|
52
|
+
|
|
53
|
+
def delete(self, uid: str) -> None:
|
|
54
|
+
"""Delete a group, first detaching every trackable that referenced it.
|
|
55
|
+
|
|
56
|
+
The server has no "tags in group X" endpoint per the JS reference
|
|
57
|
+
client (RESEARCH §4 "Delete group"), so the SDK lists trackables
|
|
58
|
+
and filters client-side, then calls ``replace_groups`` on each
|
|
59
|
+
member before the final ``DELETE /groups/{uid}``.
|
|
60
|
+
|
|
61
|
+
Step names: ``load_member_tags``, ``detach_from_tag``,
|
|
62
|
+
``delete_group``.
|
|
63
|
+
|
|
64
|
+
Detach failures do not abort the cascade — the group is still
|
|
65
|
+
deleted afterward. If any tags couldn't be detached, the SDK
|
|
66
|
+
raises :class:`PartialFailureError` AFTER the group delete
|
|
67
|
+
succeeded, with the dangling tag uids in ``partial_result``.
|
|
68
|
+
"""
|
|
69
|
+
_compound.delete_group(self._client, uid)
|
|
70
|
+
|
|
71
|
+
def add_tags(self, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
|
|
72
|
+
"""Add a group to each tag's membership, continue-on-error.
|
|
73
|
+
|
|
74
|
+
For each tag the SDK loads its current group list, appends
|
|
75
|
+
``group_uid`` (no-op if already present), and writes the new
|
|
76
|
+
list. Failures land in ``BulkResult.failures``.
|
|
77
|
+
"""
|
|
78
|
+
return _compound.add_tags(self._client, group_uid, tag_uids)
|
|
79
|
+
|
|
80
|
+
def remove_tags(self, group_uid: str, tag_uids: list[str]) -> BulkResult[None]:
|
|
81
|
+
"""Remove a group from each tag's membership, continue-on-error."""
|
|
82
|
+
return _compound.remove_tags(self._client, group_uid, tag_uids)
|
|
83
|
+
|
|
84
|
+
def list(self) -> list[Group]:
|
|
85
|
+
"""Return all groups for the current project.
|
|
86
|
+
|
|
87
|
+
Examples
|
|
88
|
+
--------
|
|
89
|
+
>>> groups = client.groups.list()
|
|
90
|
+
>>> {g.name: g.uid for g in groups}
|
|
91
|
+
{'Fleet': 'g-fleet', 'Loading bay': 'g-loading'}
|
|
92
|
+
"""
|
|
93
|
+
response = self._get("/api/v2/groups/")
|
|
94
|
+
payload = response.json()
|
|
95
|
+
items = unwrap_resource(payload, "groups")
|
|
96
|
+
return [Group.from_wire(item) for item in items]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
__all__ = ["GroupsAPI"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Logger resource sub-client (admin).
|
|
2
|
+
|
|
3
|
+
Admin-only operations for tuning the server's logging filter at runtime.
|
|
4
|
+
Most SDK consumers will never need this — included for parity with the
|
|
5
|
+
JS reference client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ._base import ResourceAPI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoggerAPI(ResourceAPI):
|
|
16
|
+
"""Get / set / clear the server's runtime logger filter (admin).
|
|
17
|
+
|
|
18
|
+
The JS reference client treats this endpoint as no-auth-required;
|
|
19
|
+
the SDK relies on the configured client identity. Filters are
|
|
20
|
+
arbitrary key/value pairs whose semantics live server-side.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def get_filter(self) -> dict[str, Any]:
|
|
24
|
+
"""Read the current server logger filter."""
|
|
25
|
+
response = self._get("/api/v2/logger/")
|
|
26
|
+
payload = response.json() if response.content else None
|
|
27
|
+
return payload if isinstance(payload, dict) else {"data": payload}
|
|
28
|
+
|
|
29
|
+
def set_filter(self, filters: dict[str, Any]) -> dict[str, Any]:
|
|
30
|
+
"""Apply / merge a new server logger filter."""
|
|
31
|
+
response = self._post("/api/v2/logger/", json=filters)
|
|
32
|
+
payload = response.json() if response.content else None
|
|
33
|
+
return payload if isinstance(payload, dict) else {"data": payload}
|
|
34
|
+
|
|
35
|
+
def clear_filter(self) -> None:
|
|
36
|
+
"""Clear the current server logger filter."""
|
|
37
|
+
self._delete("/api/v2/logger/")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["LoggerAPI"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Messaging resource sub-client.
|
|
2
|
+
|
|
3
|
+
The wire endpoint is ``POST /api/v2/messaging`` (RESEARCH Discrepancy
|
|
4
|
+
#15) — distinct from the deprecated ``/api/v2/win_message_que/create.json``
|
|
5
|
+
the JS client still defines but doesn't call. The SDK exposes only the
|
|
6
|
+
live endpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ._base import ResourceAPI
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MessagingAPI(ResourceAPI):
|
|
17
|
+
"""Send messages to trackable badges / users."""
|
|
18
|
+
|
|
19
|
+
def send(self, *, from_uid: str, to_uids: list[str], body: str) -> dict[str, Any]:
|
|
20
|
+
"""Send a message addressed to one or more recipients.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
from_uid
|
|
25
|
+
The sender's uid (typically the current user).
|
|
26
|
+
to_uids
|
|
27
|
+
Recipient uids — trackables, users, or device uids depending
|
|
28
|
+
on what your deployment routes via messaging.
|
|
29
|
+
body
|
|
30
|
+
The message text.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
dict
|
|
35
|
+
The server response. M3 surfaces it as a raw dict because
|
|
36
|
+
the wire shape isn't documented stably (RESEARCH §1 only
|
|
37
|
+
says "messages: []"); a typed model can be added later
|
|
38
|
+
without breaking callers that already treat the return as
|
|
39
|
+
opaque.
|
|
40
|
+
"""
|
|
41
|
+
payload = {
|
|
42
|
+
"from": {"uid": from_uid},
|
|
43
|
+
"to": {"uids": to_uids},
|
|
44
|
+
"body": body,
|
|
45
|
+
}
|
|
46
|
+
response = self._post("/api/v2/messaging", json=payload)
|
|
47
|
+
parsed = response.json() if response.content else None
|
|
48
|
+
return parsed if isinstance(parsed, dict) else {"data": parsed}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = ["MessagingAPI"]
|