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,68 @@
1
+ """Subscriber and system-notification subscriber models.
2
+
3
+ These are distinct entities served by separate endpoints with different
4
+ envelope keys (RESEARCH §3 Subscriber normalization). The SDK keeps them
5
+ as two types rather than collapsing — they have different concerns.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import Field, model_validator
13
+
14
+ from ._base import _BaseModel
15
+
16
+
17
+ class SubscriberAddresses(_BaseModel):
18
+ """Contact addresses on a subscriber profile.
19
+
20
+ The server emits these fields as either a list of strings or a bare
21
+ string depending on the endpoint / version (RESEARCH §1 documented
22
+ arrays; the live server has been observed returning strings). The
23
+ model coerces both shapes into a list of strings so callers don't
24
+ have to branch.
25
+ """
26
+
27
+ email: list[str] = Field(default_factory=list)
28
+ phone: list[str] = Field(default_factory=list)
29
+
30
+ @model_validator(mode="before")
31
+ @classmethod
32
+ def _coerce_to_lists(cls, data: Any) -> Any:
33
+ if not isinstance(data, dict):
34
+ return data
35
+ out = dict(data)
36
+ for key in ("email", "phone"):
37
+ value = out.get(key)
38
+ if value is None:
39
+ out[key] = []
40
+ elif isinstance(value, str):
41
+ out[key] = [value] if value else []
42
+ return out
43
+
44
+
45
+ class Subscriber(_BaseModel):
46
+ """An alarm-notifications subscriber.
47
+
48
+ Returned under ``{subscribers: [...]}`` from ``GET /subscribers/``.
49
+ """
50
+
51
+ uid: str | None = None
52
+ name: str | None = None
53
+ addresses: SubscriberAddresses | None = None
54
+ notifications: list[Any] = Field(default_factory=list)
55
+
56
+
57
+ class SystemSubscriber(_BaseModel):
58
+ """A system-event subscriber — distinct from alarm subscribers.
59
+
60
+ Returned under ``{systemNotificationSubscriber: [...]}`` from
61
+ ``GET /system_notifications/``.
62
+ """
63
+
64
+ user_uid: str | None = None
65
+ notifications: list[Any] = Field(default_factory=list)
66
+
67
+
68
+ __all__ = ["Subscriber", "SubscriberAddresses", "SystemSubscriber"]
@@ -0,0 +1,97 @@
1
+ """Host / uptime / connection / capabilities / version models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import Field
8
+
9
+ from ._base import _BaseModel
10
+
11
+
12
+ class Host(_BaseModel):
13
+ """Host info — hostname + serverStatus (disk, memory, …).
14
+
15
+ The ``server_status`` field's shape varies by deployment and Linux
16
+ distribution; surface it as a dict and let ``.raw`` carry the rest.
17
+ """
18
+
19
+ hostname: str | None = None
20
+ server_status: dict[str, Any] | None = Field(default=None, alias="serverStatus")
21
+
22
+
23
+ class Uptime(_BaseModel):
24
+ """A row from ``GET /host/uptime`` — service name + uptime data."""
25
+
26
+ name: str | None = None
27
+
28
+
29
+ class Connection(_BaseModel):
30
+ """A row from ``GET /host/connections`` — active server connection."""
31
+
32
+
33
+ class Capabilities(_BaseModel):
34
+ """SDK-detected server capabilities — SSO availability + API version.
35
+
36
+ Built by :meth:`AuthAPI.capabilities`. The ``sso`` flag reflects whether
37
+ ``GET /auth/sso/log_in`` resolves; ``api_version`` is ``"2.1"`` when SSO
38
+ is reachable, ``"2.0"`` otherwise (matches the JS reference client's
39
+ fallback heuristic, RESEARCH §4 "Probe SSO support").
40
+ """
41
+
42
+ sso: bool
43
+ api_version: str
44
+
45
+
46
+ class Version(_BaseModel):
47
+ """Server version info from ``GET /version``.
48
+
49
+ The live server emits a nested shape: ``{api: {version}, rtls_frontend:
50
+ {version}, custom_product_attributes: {...}}``. Older deployments may
51
+ use the flat shape RESEARCH originally documented. All fields are
52
+ optional; the convenience properties below pull the version string
53
+ out of whichever form the server sent.
54
+ """
55
+
56
+ api_version: str | None = None
57
+ mysql_version: str | None = None
58
+ node_version: str | None = None
59
+ modules: list[str] | dict[str, Any] | None = None
60
+ user_modules: list[str] | dict[str, Any] | None = None
61
+ api: dict[str, Any] | None = None
62
+ rtls_frontend: dict[str, Any] | None = None
63
+ custom_product_attributes: dict[str, Any] | None = None
64
+
65
+ @property
66
+ def api_version_str(self) -> str | None:
67
+ """Return the API version as a string, regardless of wire shape."""
68
+ if self.api_version:
69
+ return self.api_version
70
+ if self.api and isinstance(self.api.get("version"), str):
71
+ value: str = self.api["version"]
72
+ return value
73
+ return None
74
+
75
+ @property
76
+ def frontend_version_str(self) -> str | None:
77
+ """Return the frontend version as a string, if available."""
78
+ if self.rtls_frontend and isinstance(self.rtls_frontend.get("version"), str):
79
+ value: str = self.rtls_frontend["version"]
80
+ return value
81
+ return None
82
+
83
+
84
+ class WsHost(_BaseModel):
85
+ """The hostname for the WebSocket endpoint, returned by ``GET /wshost/``."""
86
+
87
+ ws_host: str | None = Field(default=None, alias="wsHost")
88
+
89
+
90
+ __all__ = [
91
+ "Capabilities",
92
+ "Connection",
93
+ "Host",
94
+ "Uptime",
95
+ "Version",
96
+ "WsHost",
97
+ ]
@@ -0,0 +1,36 @@
1
+ """Return type for ``system.health()``.
2
+
3
+ Aggregates three independent server-health calls into one struct with
4
+ per-call error isolation (DESIGN §4.17 / §5 R4). Dataclass because the
5
+ ``errors`` dict carries live :class:`RtlsError` instances.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from ..errors import RtlsError
13
+ from .system import Connection, Host, Uptime
14
+
15
+
16
+ @dataclass
17
+ class SystemHealth:
18
+ """Aggregated server-health snapshot.
19
+
20
+ Each sub-field is populated independently; a failure on one
21
+ sub-call sets that field to ``None`` and records the exception in
22
+ ``errors``, leaving the rest of the snapshot intact (RESEARCH §4
23
+ "Refresh System-Health dashboard" — the JS client routes each
24
+ error independently).
25
+
26
+ ``partial`` is ``True`` when any sub-call failed.
27
+ """
28
+
29
+ host: Host | None = None
30
+ uptime: list[Uptime] | None = None
31
+ connections: list[Connection] | None = None
32
+ partial: bool = False
33
+ errors: dict[str, RtlsError] = field(default_factory=dict)
34
+
35
+
36
+ __all__ = ["SystemHealth"]
rtls_sdk/models/tag.py ADDED
@@ -0,0 +1,48 @@
1
+ """Tag (trackable_object) model.
2
+
3
+ The server returns trackables under several envelope shapes (single inside
4
+ ``{trackableObjects: [t]}``, list inside the same, embedded inside
5
+ associations, etc.). The SDK normalizes to one ``Tag`` type regardless.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from pydantic import Field, model_validator
13
+
14
+ from ._base import _BaseModel
15
+ from .node import Node
16
+ from .position import Position
17
+
18
+
19
+ class Tag(_BaseModel):
20
+ """A tracked object (trackable / tag / asset).
21
+
22
+ Field aliases tolerate the embedded forms the server uses inside
23
+ other entities. Position and node sub-objects are eagerly parsed
24
+ into typed models when present.
25
+
26
+ The server has been observed to return ``groups: null`` on freshly-
27
+ created trackables (an unbound tag has no groups, but the field is
28
+ present). The SDK coerces ``None`` to ``[]`` so the typed list[str]
29
+ surface stays clean for consumers.
30
+ """
31
+
32
+ uid: str
33
+ name: str
34
+ mac_address: str | None = None
35
+ self_zone_uid: str | None = None
36
+ groups: list[str] = Field(default_factory=list)
37
+ position: Position | None = None
38
+ node: Node | None = None
39
+
40
+ @model_validator(mode="before")
41
+ @classmethod
42
+ def _coerce_null_lists(cls, data: Any) -> Any:
43
+ if isinstance(data, dict) and data.get("groups") is None:
44
+ data = {**data, "groups": []}
45
+ return data
46
+
47
+
48
+ __all__ = ["Tag"]
@@ -0,0 +1,24 @@
1
+ """Tag-template model.
2
+
3
+ Returned by the client-only endpoint ``GET /trackable_objects/templates``
4
+ (RESEARCH §1). Used as a creation prefab.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import Field
12
+
13
+ from ._base import _BaseModel
14
+
15
+
16
+ class TagTemplate(_BaseModel):
17
+ """A reusable shape for creating new trackables."""
18
+
19
+ uid: str | None = None
20
+ name: str | None = None
21
+ fields: dict[str, Any] = Field(default_factory=dict)
22
+
23
+
24
+ __all__ = ["TagTemplate"]
@@ -0,0 +1,121 @@
1
+ """User model with a derived ``role`` enum.
2
+
3
+ The server returns three boolean flags (``isSuperAdmin``,
4
+ ``isSystemUser``, ``isCompanyAdmin``) plus a raw ``role`` string. The
5
+ SDK collapses this into a single :class:`Role` enum at parse time and
6
+ keeps the raw flags accessible via ``user.raw_flags`` for power users.
7
+
8
+ Enum values match the live server's accepted role strings (confirmed
9
+ via the user-create validation error in M5: ``company_admin``,
10
+ ``project_system_admin``, ``project_operations_admin``,
11
+ ``project_maintainer``, ``project_user``, ``system_admin``). DESIGN §9
12
+ open #4 — pending-backend-confirmation — is now resolved by direct
13
+ observation of the test server.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from enum import Enum
19
+ from typing import Any
20
+
21
+ from pydantic import Field, computed_field, model_validator
22
+
23
+ from ._base import _BaseModel
24
+
25
+
26
+ class Role(str, Enum):
27
+ """Resolved user role.
28
+
29
+ Values are the server's accepted role strings. Ordering in this
30
+ declaration is informational (most-privileged first) and does not
31
+ imply comparable enums — compare by string equality only.
32
+
33
+ The boolean wire flags (``isSuperAdmin`` etc.) and the raw role
34
+ string both map into one of these members. See
35
+ :attr:`User.role` for the resolution rules.
36
+ """
37
+
38
+ SYSTEM_ADMIN = "system_admin"
39
+ COMPANY_ADMIN = "company_admin"
40
+ PROJECT_SYSTEM_ADMIN = "project_system_admin"
41
+ PROJECT_OPERATIONS_ADMIN = "project_operations_admin"
42
+ PROJECT_MAINTAINER = "project_maintainer"
43
+ PROJECT_USER = "project_user"
44
+
45
+
46
+ class UserFlags(_BaseModel):
47
+ """Raw boolean flags the server returns on every User object."""
48
+
49
+ is_super_admin: bool = Field(default=False, alias="isSuperAdmin")
50
+ is_system_user: bool = Field(default=False, alias="isSystemUser")
51
+ is_company_admin: bool = Field(default=False, alias="isCompanyAdmin")
52
+
53
+
54
+ class User(_BaseModel):
55
+ """An RTLS user.
56
+
57
+ The wire-format fields ``isSuperAdmin`` / ``isSystemUser`` /
58
+ ``isCompanyAdmin`` plus the raw ``role`` string are normalized into:
59
+
60
+ - ``role``: a single :class:`Role` enum value.
61
+ - ``raw_flags``: the four boolean flags untouched.
62
+ """
63
+
64
+ email: str
65
+ uid: str | None = None
66
+ first_name: str | None = Field(default=None, alias="first_name")
67
+ last_name: str | None = Field(default=None, alias="last_name")
68
+ raw_flags: UserFlags = Field(default_factory=UserFlags)
69
+
70
+ @model_validator(mode="before")
71
+ @classmethod
72
+ def _gather_flags(cls, data: Any) -> Any:
73
+ if not isinstance(data, dict):
74
+ return data
75
+ # Lift the four wire flags into raw_flags. We keep them on the
76
+ # outer dict too in case downstream code (or the .raw property
77
+ # via extra="allow") wants them.
78
+ flags = {
79
+ k: data[k]
80
+ for k in (
81
+ "isSuperAdmin",
82
+ "isSystemUser",
83
+ "isCompanyAdmin",
84
+ )
85
+ if k in data
86
+ }
87
+ if flags:
88
+ data = {**data, "raw_flags": flags}
89
+ return data
90
+
91
+ @computed_field # type: ignore[prop-decorator]
92
+ @property
93
+ def role(self) -> Role:
94
+ """Derived role for the user.
95
+
96
+ Resolution order (boolean flags win over raw string):
97
+
98
+ 1. ``is_super_admin`` → :attr:`Role.SYSTEM_ADMIN` (server-wide root).
99
+ 2. ``is_company_admin`` → :attr:`Role.COMPANY_ADMIN`.
100
+ 3. Raw ``role`` string, if it parses cleanly.
101
+ 4. Default → :attr:`Role.PROJECT_USER`.
102
+
103
+ The ``is_system_user`` flag is intentionally not mapped to a
104
+ Role member — the server's role vocabulary has no system-user
105
+ entry; the flag remains accessible via ``user.raw_flags.is_system_user``
106
+ for callers that need it.
107
+ """
108
+ if self.raw_flags.is_super_admin:
109
+ return Role.SYSTEM_ADMIN
110
+ if self.raw_flags.is_company_admin:
111
+ return Role.COMPANY_ADMIN
112
+ raw_role = self.raw.get("role")
113
+ if isinstance(raw_role, str):
114
+ try:
115
+ return Role(raw_role.lower())
116
+ except ValueError:
117
+ pass
118
+ return Role.PROJECT_USER
119
+
120
+
121
+ __all__ = ["Role", "User", "UserFlags"]
@@ -0,0 +1,27 @@
1
+ """Zone model.
2
+
3
+ A "zone" in RTLS-speak covers both static zones (areas defined by
4
+ operators) and buffers (dynamic zones bound to a moving trackable). The
5
+ ``dynamic`` flag discriminates: ``False`` = static zone, ``True`` = buffer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ._base import _BaseModel
11
+
12
+
13
+ class Zone(_BaseModel):
14
+ """A zone or buffer.
15
+
16
+ M1 ships the discriminator fields. M2 adds the geometric shape data
17
+ (polygon points, circle radius) and zone-type metadata.
18
+ """
19
+
20
+ uid: str
21
+ name: str
22
+ dynamic: bool = False
23
+ shape: str | None = None
24
+ trackable_object_uid: str | None = None
25
+
26
+
27
+ __all__ = ["Zone"]
@@ -0,0 +1,21 @@
1
+ """Zone-event model.
2
+
3
+ Returned by ``GET /data/zone_events?format=json``. The CSV variant is
4
+ returned as raw bytes by the reports surface in M7.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._base import _BaseModel
10
+
11
+
12
+ class ZoneEvent(_BaseModel):
13
+ """A zone-traversal event (enter / exit / dwell)."""
14
+
15
+ uid: str | None = None
16
+ zone_uid: str | None = None
17
+ trackable_object_uid: str | None = None
18
+ event_type: str | None = None
19
+
20
+
21
+ __all__ = ["ZoneEvent"]
rtls_sdk/py.typed ADDED
File without changes
@@ -0,0 +1,49 @@
1
+ """Resource sub-clients exposed on :class:`RtlsClient`."""
2
+
3
+ from .alarms import AlarmsAPI
4
+ from .anchors import AnchorsAPI
5
+ from .areas import AreasAPI
6
+ from .associations import AssociationsAPI
7
+ from .auth import AuthAPI
8
+ from .companies import CompaniesAPI
9
+ from .context import ContextAPI
10
+ from .events import EventsAPI
11
+ from .floorplans import FloorplansAPI
12
+ from .groups import GroupsAPI
13
+ from .logger import LoggerAPI
14
+ from .messaging import MessagingAPI
15
+ from .nodes import NodesAPI
16
+ from .notifications import NotificationsAPI
17
+ from .projects import ProjectsAPI
18
+ from .reports import ReportsAPI
19
+ from .sites import SitesAPI
20
+ from .subscribers import SubscribersAPI
21
+ from .system import SystemAPI
22
+ from .tags import TagsAPI
23
+ from .users import UsersAPI
24
+ from .zones import ZonesAPI
25
+
26
+ __all__ = [
27
+ "AlarmsAPI",
28
+ "AnchorsAPI",
29
+ "AreasAPI",
30
+ "AssociationsAPI",
31
+ "AuthAPI",
32
+ "CompaniesAPI",
33
+ "ContextAPI",
34
+ "EventsAPI",
35
+ "FloorplansAPI",
36
+ "GroupsAPI",
37
+ "LoggerAPI",
38
+ "MessagingAPI",
39
+ "NodesAPI",
40
+ "NotificationsAPI",
41
+ "ProjectsAPI",
42
+ "ReportsAPI",
43
+ "SitesAPI",
44
+ "SubscribersAPI",
45
+ "SystemAPI",
46
+ "TagsAPI",
47
+ "UsersAPI",
48
+ "ZonesAPI",
49
+ ]
@@ -0,0 +1,63 @@
1
+ """Base for resource sub-clients.
2
+
3
+ Each ``RtlsClient`` attribute (``client.tags``, ``client.sites``, …) is an
4
+ instance of a subclass of :class:`ResourceAPI`. The base provides the
5
+ shared request helpers; subclasses translate REST paths into typed models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import httpx
13
+
14
+ if TYPE_CHECKING:
15
+ from .._client import RtlsClient
16
+
17
+
18
+ class ResourceAPI:
19
+ """Base for every resource sub-client."""
20
+
21
+ def __init__(self, client: RtlsClient) -> None:
22
+ self._client = client
23
+
24
+ def _get(
25
+ self,
26
+ path: str,
27
+ *,
28
+ params: Any = None,
29
+ headers: dict[str, str] | None = None,
30
+ ) -> httpx.Response:
31
+ return self._client._request("GET", path, params=params, headers=headers)
32
+
33
+ def _post(
34
+ self,
35
+ path: str,
36
+ *,
37
+ json: Any = None,
38
+ params: Any = None,
39
+ headers: dict[str, str] | None = None,
40
+ ) -> httpx.Response:
41
+ return self._client._request("POST", path, json=json, params=params, headers=headers)
42
+
43
+ def _put(
44
+ self,
45
+ path: str,
46
+ *,
47
+ json: Any = None,
48
+ params: Any = None,
49
+ headers: dict[str, str] | None = None,
50
+ ) -> httpx.Response:
51
+ return self._client._request("PUT", path, json=json, params=params, headers=headers)
52
+
53
+ def _delete(
54
+ self,
55
+ path: str,
56
+ *,
57
+ params: Any = None,
58
+ headers: dict[str, str] | None = None,
59
+ ) -> httpx.Response:
60
+ return self._client._request("DELETE", path, params=params, headers=headers)
61
+
62
+
63
+ __all__ = ["ResourceAPI"]
@@ -0,0 +1,108 @@
1
+ """Alarms resource sub-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ from .._envelope import unwrap_resource, unwrap_resource_single
9
+ from .._query import pack_array_param
10
+ from .._time import to_epoch_ms
11
+ from ..models import Alarm
12
+ from ._base import ResourceAPI
13
+
14
+
15
+ class AlarmsAPI(ResourceAPI):
16
+ """Read alarm events and (clear) existing alarms.
17
+
18
+ Three time-bounded reads:
19
+
20
+ - :meth:`list_active` — currently-open alarms.
21
+ - :meth:`list_for_site` — alarms in a site over a window.
22
+ - :meth:`list_for_area` — alarms in a single sublocation over a window.
23
+
24
+ The CSV export variants of these endpoints are deferred to the
25
+ reports surface in M7 (``client.reports.alarms_csv``).
26
+ """
27
+
28
+ def list_active(self) -> list[Alarm]:
29
+ response = self._get("/api/v2/alarms/active/")
30
+ items = unwrap_resource(response.json(), "alarms")
31
+ return [Alarm.from_wire(item) for item in items]
32
+
33
+ def list_for_site(
34
+ self,
35
+ start: datetime,
36
+ end: datetime,
37
+ *,
38
+ site_uid: str,
39
+ alarm_types: list[str] | None = None,
40
+ ) -> list[Alarm]:
41
+ """Return alarms raised at ``site_uid`` in ``[start, end]``.
42
+
43
+ ``alarm_types`` filters to specific alarm-type names (e.g.
44
+ ``["zone_violation", "buffer_violation"]``); ``None`` lists all.
45
+ Timestamps are converted to epoch milliseconds per the server's
46
+ contract (RESEARCH §2). Naive ``datetime`` raises ``ValueError``.
47
+ """
48
+ params: list[tuple[str, str]] = [
49
+ ("from", str(to_epoch_ms(start, arg_name="start"))),
50
+ ("to", str(to_epoch_ms(end, arg_name="end"))),
51
+ ("location_uid", site_uid),
52
+ ("format", "json"),
53
+ ("report", "1"),
54
+ ]
55
+ if alarm_types:
56
+ params.extend(pack_array_param("alarm_type_name", alarm_types))
57
+ response = self._get("/api/v2/alarms", params=params)
58
+ items = unwrap_resource(response.json(), "alarms")
59
+ return [Alarm.from_wire(item) for item in items]
60
+
61
+ def list_for_area(
62
+ self,
63
+ start: datetime,
64
+ end: datetime,
65
+ *,
66
+ area_uid: str,
67
+ alarm_types: list[str] | None = None,
68
+ ) -> list[Alarm]:
69
+ """Return alarms raised in a sublocation over a window.
70
+
71
+ Same conventions as :meth:`list_for_site`; the scoping parameter
72
+ is ``sublocation_uid`` instead of ``location_uid``.
73
+ """
74
+ params: list[tuple[str, str]] = [
75
+ ("from", str(to_epoch_ms(start, arg_name="start"))),
76
+ ("to", str(to_epoch_ms(end, arg_name="end"))),
77
+ ("sublocation_uid", area_uid),
78
+ ("format", "json"),
79
+ ("report", "1"),
80
+ ]
81
+ if alarm_types:
82
+ params.extend(pack_array_param("alarm_type_name", alarm_types))
83
+ response = self._get("/api/v2/alarms", params=params)
84
+ items = unwrap_resource(response.json(), "alarms")
85
+ return [Alarm.from_wire(item) for item in items]
86
+
87
+ def update(self, uid: str, **fields: Any) -> Alarm:
88
+ """Update / clear an alarm.
89
+
90
+ Uses **PUT** despite the public docs saying POST — the JS
91
+ reference client uses PUT and the server accepts only PUT
92
+ (RESEARCH Discrepancy #3).
93
+ """
94
+ response = self._put(f"/api/v2/alarms/{uid}", json=fields)
95
+ payload = response.json()
96
+ try:
97
+ item = unwrap_resource_single(payload, "alarms")
98
+ except ValueError:
99
+ item = payload if isinstance(payload, dict) else {}
100
+ # The server may also return the alarm under the bare-array
101
+ # envelope ``{alarms: [...]}``; take the first row if so.
102
+ if isinstance(item, dict) and "alarms" in item and isinstance(item["alarms"], list):
103
+ first = item["alarms"][0] if item["alarms"] else {}
104
+ item = first if isinstance(first, dict) else {}
105
+ return Alarm.from_wire(item if isinstance(item, dict) else {})
106
+
107
+
108
+ __all__ = ["AlarmsAPI"]