nexalware 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nexalware
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: nexalware
3
+ Version: 0.1.0
4
+ Summary: Typed client for the Nexalware API - control physical devices and read their telemetry from any Python agent or app.
5
+ Author: Nexalware
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://nexalware.com
8
+ Project-URL: Documentation, https://docs.nexalware.com/docs/sdk/sdk-python
9
+ Project-URL: Repository, https://github.com/Darrey1/nexalware-homepage
10
+ Project-URL: Issues, https://github.com/Darrey1/nexalware-homepage/issues
11
+ Keywords: nexalware,iot,device-control,robotics,sdk
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Typing :: Typed
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Home Automation
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # nexalware
24
+
25
+ Typed client for the [Nexalware](https://nexalware.com) API, control physical devices and read their telemetry from any Python agent or app. Zero third-party dependencies, built on `urllib` from the standard library, so dropping it into any existing agent environment can never trigger a version conflict.
26
+
27
+ Writing in TypeScript instead? See [`@nexalware/sdk`](https://www.npmjs.com/package/@nexalware/sdk) on npm. Want an MCP-aware host to discover these as tools automatically instead of calling them from code? See [`@nexalware/mcp`](https://www.npmjs.com/package/@nexalware/mcp).
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install nexalware
33
+ ```
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ from nexalware import NexalwareClient
39
+
40
+ client = NexalwareClient(api_key="nxw_live_sk_your_key_here")
41
+
42
+ client.turn_on("dev_a1b2c3")
43
+
44
+ latest = client.get_latest_telemetry("dev_a1b2c3")
45
+ print(latest["state"], latest["telemetry"])
46
+ ```
47
+
48
+ Get an API key from the [dashboard](https://nexalware.com) (API Keys), and make sure it has a DeviceGrant covering the device(s) and command(s) you call, an ungranted key authenticates fine but every call is rejected with a 403.
49
+
50
+ > **Field names match the API's own JSON, not snake_case.** Return values are plain `dict`s (typed as `TypedDict` for editor/type-checker support), with the same field names the REST API itself uses, e.g. `latest["subDeviceId"]`, not `latest["sub_device_id"]`. Method and argument names are proper Python `snake_case`, only the data payloads keep the wire format.
51
+
52
+ ## `NexalwareClient(api_key, base_url=...)`
53
+
54
+ | Param | Type | Required | Meaning |
55
+ |---|---|---|---|
56
+ | `api_key` | str | yes | A secret key from the dashboard. |
57
+ | `base_url` | str | no | Override for a self-hosted or staging deployment. Defaults to `https://api.nexalware.com`. |
58
+ | `timeout` | float | no | Abort a request after this many seconds. Defaults to `30.0`. |
59
+
60
+ ## Errors
61
+
62
+ Every method raises `NexalwareApiError` on a non-2xx response, it never returns a "silent" error value.
63
+
64
+ | Attribute | Type | Meaning |
65
+ |---|---|---|
66
+ | `status` | int | HTTP status code. |
67
+ | `error` | str | Short machine-readable code, e.g. `"FORBIDDEN"`, `"NOT_FOUND"`. |
68
+ | `message` | str \| None | Human-readable reason, same text a dashboard user would see. |
69
+ | `details` | list \| None | Only present on a 400 validation failure. |
70
+
71
+ ```python
72
+ from nexalware import NexalwareApiError
73
+
74
+ try:
75
+ client.send_command("dev_a1b2c3", "SET_BRIGHTNESS", params={"level": 60})
76
+ except NexalwareApiError as err:
77
+ print(err.status, err.error, err.message)
78
+ ```
79
+
80
+ ## Methods
81
+
82
+ - `list_devices(project_id=None)` - the devices this key can actually act on, only what its own DeviceGrant(s) cover. Call this first to discover valid `device_id` values instead of needing them hardcoded or pasted in.
83
+ - `get_commands(device_id)` - the command catalog this device accepts.
84
+ - `send_command(device_id, cmd, params=None, target=None)` - send a command to a device, or, with `target` set, to one specific sub-device behind it.
85
+ - `turn_on(device_id)` / `turn_off(device_id)` - shorthand for `send_command(device_id, "ON" | "OFF")`.
86
+ - `get_telemetry(device_id, metric=None, limit=None, since=None)` - historical telemetry readings, newest first.
87
+ - `get_latest_telemetry(device_id)` - current state snapshot plus the most recent reading per metric.
88
+ - `list_sub_devices(device_id)` / `get_sub_device(device_id, sub_device_id)` - physical devices connected locally behind a master device.
89
+ - `get_sub_device_telemetry(device_id, sub_device_id, metric=None, limit=None, since=None)` - same shape as `get_telemetry`, scoped to one sub-device.
90
+ - `send_sub_device_command(device_id, sub_device_id, cmd, params=None)` - convenience wrapper over `send_command` with `target` already set.
91
+ - `list_schedules(device_id)` / `get_schedule_context(device_id)` - a device's active schedules, and the commands available to schedule.
92
+ - `create_schedule(device_id, slot, on_ts, off_ts, ...)` / `update_schedule(...)` / `delete_schedule(device_id, slot)` - manage a device's schedule slots (0-4).
93
+ - `get_schedule_history(device_id)` - a device's completed or cancelled schedules, most recent first.
94
+
95
+ Full parameter/return types for every method: **[SDK Reference](https://docs.nexalware.com/docs/sdk/sdk-python)**.
96
+
97
+ ## Links
98
+
99
+ - [Docs](https://docs.nexalware.com)
100
+ - [Device Orchestration](https://docs.nexalware.com/docs/device-orchestration) - sub-devices, and the contract a master implements to report them.
101
+ - [Authentication & Access](https://docs.nexalware.com/docs/concepts/authentication-and-access) - API keys and DeviceGrants.
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,83 @@
1
+ # nexalware
2
+
3
+ Typed client for the [Nexalware](https://nexalware.com) API, control physical devices and read their telemetry from any Python agent or app. Zero third-party dependencies, built on `urllib` from the standard library, so dropping it into any existing agent environment can never trigger a version conflict.
4
+
5
+ Writing in TypeScript instead? See [`@nexalware/sdk`](https://www.npmjs.com/package/@nexalware/sdk) on npm. Want an MCP-aware host to discover these as tools automatically instead of calling them from code? See [`@nexalware/mcp`](https://www.npmjs.com/package/@nexalware/mcp).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install nexalware
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ```python
16
+ from nexalware import NexalwareClient
17
+
18
+ client = NexalwareClient(api_key="nxw_live_sk_your_key_here")
19
+
20
+ client.turn_on("dev_a1b2c3")
21
+
22
+ latest = client.get_latest_telemetry("dev_a1b2c3")
23
+ print(latest["state"], latest["telemetry"])
24
+ ```
25
+
26
+ Get an API key from the [dashboard](https://nexalware.com) (API Keys), and make sure it has a DeviceGrant covering the device(s) and command(s) you call, an ungranted key authenticates fine but every call is rejected with a 403.
27
+
28
+ > **Field names match the API's own JSON, not snake_case.** Return values are plain `dict`s (typed as `TypedDict` for editor/type-checker support), with the same field names the REST API itself uses, e.g. `latest["subDeviceId"]`, not `latest["sub_device_id"]`. Method and argument names are proper Python `snake_case`, only the data payloads keep the wire format.
29
+
30
+ ## `NexalwareClient(api_key, base_url=...)`
31
+
32
+ | Param | Type | Required | Meaning |
33
+ |---|---|---|---|
34
+ | `api_key` | str | yes | A secret key from the dashboard. |
35
+ | `base_url` | str | no | Override for a self-hosted or staging deployment. Defaults to `https://api.nexalware.com`. |
36
+ | `timeout` | float | no | Abort a request after this many seconds. Defaults to `30.0`. |
37
+
38
+ ## Errors
39
+
40
+ Every method raises `NexalwareApiError` on a non-2xx response, it never returns a "silent" error value.
41
+
42
+ | Attribute | Type | Meaning |
43
+ |---|---|---|
44
+ | `status` | int | HTTP status code. |
45
+ | `error` | str | Short machine-readable code, e.g. `"FORBIDDEN"`, `"NOT_FOUND"`. |
46
+ | `message` | str \| None | Human-readable reason, same text a dashboard user would see. |
47
+ | `details` | list \| None | Only present on a 400 validation failure. |
48
+
49
+ ```python
50
+ from nexalware import NexalwareApiError
51
+
52
+ try:
53
+ client.send_command("dev_a1b2c3", "SET_BRIGHTNESS", params={"level": 60})
54
+ except NexalwareApiError as err:
55
+ print(err.status, err.error, err.message)
56
+ ```
57
+
58
+ ## Methods
59
+
60
+ - `list_devices(project_id=None)` - the devices this key can actually act on, only what its own DeviceGrant(s) cover. Call this first to discover valid `device_id` values instead of needing them hardcoded or pasted in.
61
+ - `get_commands(device_id)` - the command catalog this device accepts.
62
+ - `send_command(device_id, cmd, params=None, target=None)` - send a command to a device, or, with `target` set, to one specific sub-device behind it.
63
+ - `turn_on(device_id)` / `turn_off(device_id)` - shorthand for `send_command(device_id, "ON" | "OFF")`.
64
+ - `get_telemetry(device_id, metric=None, limit=None, since=None)` - historical telemetry readings, newest first.
65
+ - `get_latest_telemetry(device_id)` - current state snapshot plus the most recent reading per metric.
66
+ - `list_sub_devices(device_id)` / `get_sub_device(device_id, sub_device_id)` - physical devices connected locally behind a master device.
67
+ - `get_sub_device_telemetry(device_id, sub_device_id, metric=None, limit=None, since=None)` - same shape as `get_telemetry`, scoped to one sub-device.
68
+ - `send_sub_device_command(device_id, sub_device_id, cmd, params=None)` - convenience wrapper over `send_command` with `target` already set.
69
+ - `list_schedules(device_id)` / `get_schedule_context(device_id)` - a device's active schedules, and the commands available to schedule.
70
+ - `create_schedule(device_id, slot, on_ts, off_ts, ...)` / `update_schedule(...)` / `delete_schedule(device_id, slot)` - manage a device's schedule slots (0-4).
71
+ - `get_schedule_history(device_id)` - a device's completed or cancelled schedules, most recent first.
72
+
73
+ Full parameter/return types for every method: **[SDK Reference](https://docs.nexalware.com/docs/sdk/sdk-python)**.
74
+
75
+ ## Links
76
+
77
+ - [Docs](https://docs.nexalware.com)
78
+ - [Device Orchestration](https://docs.nexalware.com/docs/device-orchestration) - sub-devices, and the contract a master implements to report them.
79
+ - [Authentication & Access](https://docs.nexalware.com/docs/concepts/authentication-and-access) - API keys and DeviceGrants.
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nexalware"
7
+ version = "0.1.0"
8
+ description = "Typed client for the Nexalware API - control physical devices and read their telemetry from any Python agent or app."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Nexalware" }]
13
+ keywords = ["nexalware", "iot", "device-control", "robotics", "sdk"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Typing :: Typed",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ "Topic :: Home Automation",
21
+ ]
22
+ # Deliberately zero third-party dependencies, built on urllib from the
23
+ # standard library, so dropping this into any existing agent environment
24
+ # can never trigger a dependency conflict.
25
+ dependencies = []
26
+
27
+ [project.urls]
28
+ Homepage = "https://nexalware.com"
29
+ Documentation = "https://docs.nexalware.com/docs/sdk/sdk-python"
30
+ Repository = "https://github.com/Darrey1/nexalware-homepage"
31
+ Issues = "https://github.com/Darrey1/nexalware-homepage/issues"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.setuptools.package-data]
37
+ nexalware = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ from .client import DEFAULT_BASE_URL, NexalwareClient
4
+ from .errors import NexalwareApiError
5
+ from .types import (
6
+ Action,
7
+ CommandDefinition,
8
+ Device,
9
+ LatestTelemetry,
10
+ Schedule,
11
+ SendCommandResult,
12
+ SubDevice,
13
+ SubDeviceCapabilities,
14
+ TelemetryReading,
15
+ )
16
+
17
+ __all__ = [
18
+ "NexalwareClient",
19
+ "NexalwareApiError",
20
+ "DEFAULT_BASE_URL",
21
+ "Action",
22
+ "CommandDefinition",
23
+ "Device",
24
+ "LatestTelemetry",
25
+ "Schedule",
26
+ "SendCommandResult",
27
+ "SubDevice",
28
+ "SubDeviceCapabilities",
29
+ "TelemetryReading",
30
+ ]
31
+
32
+ try:
33
+ # Single source of truth is pyproject.toml's version - read it back at
34
+ # runtime instead of hand-duplicating it here, so the two can never
35
+ # drift out of sync.
36
+ __version__ = version("nexalware")
37
+ except PackageNotFoundError:
38
+ # Editable/local checkout with no installed distribution metadata yet.
39
+ __version__ = "0.0.0+unknown"
@@ -0,0 +1,277 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any, Dict, List, Optional, cast
9
+
10
+ from .errors import NexalwareApiError
11
+ from .types import (
12
+ Action,
13
+ CommandDefinition,
14
+ Device,
15
+ LatestTelemetry,
16
+ Schedule,
17
+ SendCommandResult,
18
+ SubDevice,
19
+ TelemetryReading,
20
+ )
21
+
22
+ DEFAULT_BASE_URL = "https://api.nexalware.com"
23
+ DEFAULT_TIMEOUT_SECONDS = 30.0
24
+
25
+
26
+ class NexalwareClient:
27
+ """Thin typed wrapper over the Nexalware "Device Control" API tier, the
28
+ same endpoints an API key can already call today. Built on ``urllib``
29
+ from the standard library only, no third-party dependency, so dropping
30
+ this into any existing agent environment can never trigger a version
31
+ conflict.
32
+
33
+ A key's actual reach is decided entirely by its DeviceGrant(s) on the
34
+ dashboard, this client never expands or assumes permissions beyond that.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ api_key: str,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
42
+ ) -> None:
43
+ if not api_key:
44
+ raise ValueError("NexalwareClient requires an api_key")
45
+ self._api_key = api_key
46
+ self._base_url = base_url.rstrip("/")
47
+ self._timeout = timeout
48
+
49
+ def _request(
50
+ self,
51
+ method: str,
52
+ path: str,
53
+ body: Optional[Dict[str, Any]] = None,
54
+ query: Optional[Dict[str, Any]] = None,
55
+ ) -> Any:
56
+ url = f"{self._base_url}/api/v1{path}"
57
+ if query:
58
+ filtered = {k: v for k, v in query.items() if v is not None}
59
+ if filtered:
60
+ url += "?" + urllib.parse.urlencode(filtered)
61
+
62
+ data = json.dumps(body).encode("utf-8") if body is not None else None
63
+ request = urllib.request.Request(
64
+ url,
65
+ data=data,
66
+ method=method,
67
+ headers={
68
+ "X-Api-Key": self._api_key,
69
+ "Content-Type": "application/json",
70
+ "User-Agent": "nexalware-sdk-python",
71
+ },
72
+ )
73
+ try:
74
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
75
+ text = response.read().decode("utf-8")
76
+ if not text:
77
+ return None
78
+ try:
79
+ return json.loads(text)
80
+ except ValueError:
81
+ raise NexalwareApiError(
82
+ status=response.status,
83
+ error="INVALID_RESPONSE",
84
+ message=f"Nexalware API returned a non-JSON response from {path}",
85
+ ) from None
86
+ except urllib.error.HTTPError as err:
87
+ text = err.read().decode("utf-8")
88
+ # The API always returns JSON, but a proxy/CDN error page in
89
+ # front of it (502, WAF block) won't - fail with a clear
90
+ # NexalwareApiError either way, not an unhandled ValueError.
91
+ try:
92
+ payload = json.loads(text) if text else {}
93
+ except ValueError:
94
+ payload = {"message": text[:200]} if text else {}
95
+ raise NexalwareApiError(
96
+ status=err.code,
97
+ error=payload.get("error", "UNKNOWN_ERROR"),
98
+ message=payload.get("message"),
99
+ details=payload.get("details"),
100
+ ) from None
101
+ except socket.timeout:
102
+ # socket.timeout is TimeoutError itself on 3.10+, but stays a
103
+ # distinct class pre-3.10 (this package supports 3.9+), so
104
+ # catch it by its always-correct name rather than TimeoutError.
105
+ raise TimeoutError(f"Nexalware API request to {path} timed out after {self._timeout}s") from None
106
+
107
+ def list_devices(self, project_id: Optional[str] = None) -> List[Device]:
108
+ """The devices this key can actually act on - only what its own
109
+ DeviceGrant(s) cover, never the rest of the account. Call this
110
+ first to discover valid device_id values instead of needing them
111
+ hardcoded or pasted in from the dashboard.
112
+ """
113
+ return cast(List[Device], self._request("GET", "/devices", query={"projectId": project_id}))
114
+
115
+ def get_commands(self, device_id: str) -> List[CommandDefinition]:
116
+ """The command catalog this device accepts."""
117
+ return cast(List[CommandDefinition], self._request("GET", f"/devices/{device_id}/commands"))
118
+
119
+ def send_command(
120
+ self,
121
+ device_id: str,
122
+ cmd: str,
123
+ params: Optional[Dict[str, Any]] = None,
124
+ target: Optional[str] = None,
125
+ ) -> SendCommandResult:
126
+ """Send a command to a device, or, with ``target`` set, to one
127
+ specific sub-device behind it (see ``send_sub_device_command`` for
128
+ the convenience form). Raises ``NexalwareApiError`` (status 403) if
129
+ the calling key has no DeviceGrant covering this command.
130
+ """
131
+ body: Dict[str, Any] = {"cmd": cmd}
132
+ if params is not None:
133
+ body["params"] = params
134
+ if target is not None:
135
+ body["target"] = target
136
+ return cast(SendCommandResult, self._request("POST", f"/devices/{device_id}/command", body=body))
137
+
138
+ def turn_on(self, device_id: str) -> SendCommandResult:
139
+ """Shorthand for ``send_command(device_id, "ON")``."""
140
+ return cast(SendCommandResult, self._request("POST", f"/devices/{device_id}/command/on"))
141
+
142
+ def turn_off(self, device_id: str) -> SendCommandResult:
143
+ """Shorthand for ``send_command(device_id, "OFF")``."""
144
+ return cast(SendCommandResult, self._request("POST", f"/devices/{device_id}/command/off"))
145
+
146
+ def get_telemetry(
147
+ self,
148
+ device_id: str,
149
+ metric: Optional[str] = None,
150
+ limit: Optional[int] = None,
151
+ since: Optional[int] = None,
152
+ ) -> List[TelemetryReading]:
153
+ """Historical telemetry readings, newest first."""
154
+ return cast(
155
+ List[TelemetryReading],
156
+ self._request(
157
+ "GET",
158
+ f"/devices/{device_id}/telemetry",
159
+ query={"metric": metric, "limit": limit, "since": since},
160
+ ),
161
+ )
162
+
163
+ def get_latest_telemetry(self, device_id: str) -> LatestTelemetry:
164
+ """The device's current state plus the most recent reading per metric."""
165
+ return cast(LatestTelemetry, self._request("GET", f"/devices/{device_id}/telemetry/latest"))
166
+
167
+ def list_sub_devices(self, device_id: str) -> List[SubDevice]:
168
+ """Physical devices connected locally behind this one, acting as an
169
+ orchestrator ("master"). Empty until the master actually reports one.
170
+ """
171
+ return cast(List[SubDevice], self._request("GET", f"/devices/{device_id}/sub-devices"))
172
+
173
+ def get_sub_device(self, device_id: str, sub_device_id: str) -> SubDevice:
174
+ return cast(SubDevice, self._request("GET", f"/devices/{device_id}/sub-devices/{sub_device_id}"))
175
+
176
+ def get_sub_device_telemetry(
177
+ self,
178
+ device_id: str,
179
+ sub_device_id: str,
180
+ metric: Optional[str] = None,
181
+ limit: Optional[int] = None,
182
+ since: Optional[int] = None,
183
+ ) -> List[TelemetryReading]:
184
+ return cast(
185
+ List[TelemetryReading],
186
+ self._request(
187
+ "GET",
188
+ f"/devices/{device_id}/sub-devices/{sub_device_id}/telemetry",
189
+ query={"metric": metric, "limit": limit, "since": since},
190
+ ),
191
+ )
192
+
193
+ def send_sub_device_command(
194
+ self,
195
+ device_id: str,
196
+ sub_device_id: str,
197
+ cmd: str,
198
+ params: Optional[Dict[str, Any]] = None,
199
+ ) -> SendCommandResult:
200
+ """Convenience wrapper over ``send_command`` with ``target`` set to a sub-device."""
201
+ return self.send_command(device_id, cmd, params, target=sub_device_id)
202
+
203
+ def list_schedules(self, device_id: str) -> List[Schedule]:
204
+ """A device's active (pending or currently running) schedules."""
205
+ return cast(List[Schedule], self._request("GET", f"/devices/{device_id}/schedules"))
206
+
207
+ def get_schedule_context(self, device_id: str) -> Dict[str, List[CommandDefinition]]:
208
+ """The commands available to schedule, same catalog ``get_commands`` returns."""
209
+ return cast(
210
+ Dict[str, List[CommandDefinition]],
211
+ self._request("GET", f"/devices/{device_id}/schedules/context"),
212
+ )
213
+
214
+ def create_schedule(
215
+ self,
216
+ device_id: str,
217
+ slot: int,
218
+ on_ts: int,
219
+ off_ts: int,
220
+ label: Optional[str] = None,
221
+ enabled: Optional[bool] = None,
222
+ on_command: Optional[Action] = None,
223
+ off_command: Optional[Action] = None,
224
+ ) -> Schedule:
225
+ """Create or replace one of a device's schedule slots (0-4), firing
226
+ ``on_command`` at ``on_ts`` and ``off_command`` at ``off_ts``.
227
+ ``on_command``/``off_command`` default to plain ON/OFF, any other
228
+ command still fires reliably from the server at the scheduled time,
229
+ but only the plain ON/OFF case also syncs to firmware for offline
230
+ self-execution. Raises ``NexalwareApiError`` (status 403) if the
231
+ calling key has no permission for either command.
232
+ """
233
+ body: Dict[str, Any] = {"slot": slot, "onTs": on_ts, "offTs": off_ts}
234
+ if label is not None:
235
+ body["label"] = label
236
+ if enabled is not None:
237
+ body["enabled"] = enabled
238
+ if on_command is not None:
239
+ body["onCommand"] = on_command
240
+ if off_command is not None:
241
+ body["offCommand"] = off_command
242
+ return cast(Schedule, self._request("POST", f"/devices/{device_id}/schedules", body=body))
243
+
244
+ def update_schedule(
245
+ self,
246
+ device_id: str,
247
+ slot: int,
248
+ on_ts: Optional[int] = None,
249
+ off_ts: Optional[int] = None,
250
+ label: Optional[str] = None,
251
+ enabled: Optional[bool] = None,
252
+ on_command: Optional[Action] = None,
253
+ off_command: Optional[Action] = None,
254
+ ) -> Schedule:
255
+ """Update an existing schedule slot, only the fields provided are changed."""
256
+ body: Dict[str, Any] = {}
257
+ if on_ts is not None:
258
+ body["onTs"] = on_ts
259
+ if off_ts is not None:
260
+ body["offTs"] = off_ts
261
+ if label is not None:
262
+ body["label"] = label
263
+ if enabled is not None:
264
+ body["enabled"] = enabled
265
+ if on_command is not None:
266
+ body["onCommand"] = on_command
267
+ if off_command is not None:
268
+ body["offCommand"] = off_command
269
+ return cast(Schedule, self._request("PUT", f"/devices/{device_id}/schedules/{slot}", body=body))
270
+
271
+ def delete_schedule(self, device_id: str, slot: int) -> Dict[str, bool]:
272
+ """Cancel a schedule slot."""
273
+ return cast(Dict[str, bool], self._request("DELETE", f"/devices/{device_id}/schedules/{slot}"))
274
+
275
+ def get_schedule_history(self, device_id: str) -> List[Schedule]:
276
+ """A device's completed or cancelled schedules, most recent first."""
277
+ return cast(List[Schedule], self._request("GET", f"/devices/{device_id}/schedules/history"))
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional
4
+
5
+
6
+ class NexalwareApiError(Exception):
7
+ """Raised for any non-2xx response from the Nexalware API. Mirrors the
8
+ platform's own error envelope (``{ error, message?, details? }``) so
9
+ callers get the same reason a dashboard user or curl call would see,
10
+ not a generic HTTP failure.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ status: int,
16
+ error: str,
17
+ message: Optional[str] = None,
18
+ details: Optional[List[Any]] = None,
19
+ ) -> None:
20
+ self.status = status
21
+ self.error = error
22
+ self.message = message
23
+ self.details = details
24
+ super().__init__(message or error or f"Nexalware API request failed with status {status}")
File without changes
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
4
+
5
+ # Field names deliberately match the API's own JSON wire format (camelCase)
6
+ # rather than being renamed to snake_case, so an example or error message
7
+ # from the docs, the REST API, or this SDK all refer to the same field by
8
+ # the same name, nothing to mentally translate between them.
9
+
10
+
11
+ class DeviceProject(TypedDict):
12
+ name: str
13
+ projectId: str
14
+
15
+
16
+ class Device(TypedDict):
17
+ deviceId: str
18
+ name: str
19
+ boardType: str
20
+ appType: str
21
+ deviceStatus: Literal["PENDING", "ACTIVE", "DISABLED", "REVOKED"]
22
+ enabled: bool
23
+ deviceTypeId: Optional[str]
24
+ relayState: Optional[bool]
25
+ isOnline: bool
26
+ lastSeen: Optional[str]
27
+ createdAt: str
28
+ project: Optional[DeviceProject]
29
+
30
+
31
+ class CommandDefinition(TypedDict, total=False):
32
+ commandId: str
33
+ name: str
34
+ label: str
35
+ description: str
36
+ kind: Literal["ACTION", "QUERY"]
37
+ paramsSchema: Optional[Dict[str, Any]]
38
+ shape: Optional[Dict[str, Any]]
39
+ requiresApproval: bool
40
+
41
+
42
+ class SendCommandResult(TypedDict, total=False):
43
+ ok: Literal[True]
44
+ approvalRequired: bool
45
+ approvalId: str
46
+
47
+
48
+ class TelemetryReading(TypedDict):
49
+ id: str
50
+ deviceId: str
51
+ accountId: str
52
+ subDeviceId: Optional[str]
53
+ metric: str
54
+ value: Optional[float]
55
+ valueText: Optional[str]
56
+ unit: Optional[str]
57
+ raw: Any
58
+ recordedAt: str
59
+
60
+
61
+ class LatestTelemetry(TypedDict):
62
+ state: Optional[Dict[str, Any]]
63
+ relayState: Optional[Literal["ON", "OFF"]]
64
+ telemetry: List[TelemetryReading]
65
+
66
+
67
+ class SubDeviceCapabilities(TypedDict, total=False):
68
+ commands: List[Dict[str, Any]]
69
+ telemetry_metrics: List[str]
70
+ verifiable: bool
71
+
72
+
73
+ class SubDevice(TypedDict):
74
+ subDeviceId: str
75
+ externalId: str
76
+ name: str
77
+ state: Optional[Dict[str, Any]]
78
+ capabilities: Optional[SubDeviceCapabilities]
79
+ verifiable: bool
80
+ enabled: bool
81
+ isOnline: bool
82
+ lastSeen: Optional[str]
83
+ createdAt: str
84
+
85
+
86
+ class Action(TypedDict, total=False):
87
+ command: str
88
+ params: Dict[str, Any]
89
+
90
+
91
+ class Schedule(TypedDict):
92
+ scheduleId: str
93
+ deviceId: str
94
+ slot: int
95
+ onTs: int
96
+ offTs: int
97
+ label: str
98
+ enabled: bool
99
+ status: Literal["PENDING", "ACTIVE", "COMPLETED", "CANCELLED"]
100
+ onCommand: Action
101
+ offCommand: Action
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: nexalware
3
+ Version: 0.1.0
4
+ Summary: Typed client for the Nexalware API - control physical devices and read their telemetry from any Python agent or app.
5
+ Author: Nexalware
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://nexalware.com
8
+ Project-URL: Documentation, https://docs.nexalware.com/docs/sdk/sdk-python
9
+ Project-URL: Repository, https://github.com/Darrey1/nexalware-homepage
10
+ Project-URL: Issues, https://github.com/Darrey1/nexalware-homepage/issues
11
+ Keywords: nexalware,iot,device-control,robotics,sdk
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Typing :: Typed
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Topic :: Home Automation
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # nexalware
24
+
25
+ Typed client for the [Nexalware](https://nexalware.com) API, control physical devices and read their telemetry from any Python agent or app. Zero third-party dependencies, built on `urllib` from the standard library, so dropping it into any existing agent environment can never trigger a version conflict.
26
+
27
+ Writing in TypeScript instead? See [`@nexalware/sdk`](https://www.npmjs.com/package/@nexalware/sdk) on npm. Want an MCP-aware host to discover these as tools automatically instead of calling them from code? See [`@nexalware/mcp`](https://www.npmjs.com/package/@nexalware/mcp).
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install nexalware
33
+ ```
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ from nexalware import NexalwareClient
39
+
40
+ client = NexalwareClient(api_key="nxw_live_sk_your_key_here")
41
+
42
+ client.turn_on("dev_a1b2c3")
43
+
44
+ latest = client.get_latest_telemetry("dev_a1b2c3")
45
+ print(latest["state"], latest["telemetry"])
46
+ ```
47
+
48
+ Get an API key from the [dashboard](https://nexalware.com) (API Keys), and make sure it has a DeviceGrant covering the device(s) and command(s) you call, an ungranted key authenticates fine but every call is rejected with a 403.
49
+
50
+ > **Field names match the API's own JSON, not snake_case.** Return values are plain `dict`s (typed as `TypedDict` for editor/type-checker support), with the same field names the REST API itself uses, e.g. `latest["subDeviceId"]`, not `latest["sub_device_id"]`. Method and argument names are proper Python `snake_case`, only the data payloads keep the wire format.
51
+
52
+ ## `NexalwareClient(api_key, base_url=...)`
53
+
54
+ | Param | Type | Required | Meaning |
55
+ |---|---|---|---|
56
+ | `api_key` | str | yes | A secret key from the dashboard. |
57
+ | `base_url` | str | no | Override for a self-hosted or staging deployment. Defaults to `https://api.nexalware.com`. |
58
+ | `timeout` | float | no | Abort a request after this many seconds. Defaults to `30.0`. |
59
+
60
+ ## Errors
61
+
62
+ Every method raises `NexalwareApiError` on a non-2xx response, it never returns a "silent" error value.
63
+
64
+ | Attribute | Type | Meaning |
65
+ |---|---|---|
66
+ | `status` | int | HTTP status code. |
67
+ | `error` | str | Short machine-readable code, e.g. `"FORBIDDEN"`, `"NOT_FOUND"`. |
68
+ | `message` | str \| None | Human-readable reason, same text a dashboard user would see. |
69
+ | `details` | list \| None | Only present on a 400 validation failure. |
70
+
71
+ ```python
72
+ from nexalware import NexalwareApiError
73
+
74
+ try:
75
+ client.send_command("dev_a1b2c3", "SET_BRIGHTNESS", params={"level": 60})
76
+ except NexalwareApiError as err:
77
+ print(err.status, err.error, err.message)
78
+ ```
79
+
80
+ ## Methods
81
+
82
+ - `list_devices(project_id=None)` - the devices this key can actually act on, only what its own DeviceGrant(s) cover. Call this first to discover valid `device_id` values instead of needing them hardcoded or pasted in.
83
+ - `get_commands(device_id)` - the command catalog this device accepts.
84
+ - `send_command(device_id, cmd, params=None, target=None)` - send a command to a device, or, with `target` set, to one specific sub-device behind it.
85
+ - `turn_on(device_id)` / `turn_off(device_id)` - shorthand for `send_command(device_id, "ON" | "OFF")`.
86
+ - `get_telemetry(device_id, metric=None, limit=None, since=None)` - historical telemetry readings, newest first.
87
+ - `get_latest_telemetry(device_id)` - current state snapshot plus the most recent reading per metric.
88
+ - `list_sub_devices(device_id)` / `get_sub_device(device_id, sub_device_id)` - physical devices connected locally behind a master device.
89
+ - `get_sub_device_telemetry(device_id, sub_device_id, metric=None, limit=None, since=None)` - same shape as `get_telemetry`, scoped to one sub-device.
90
+ - `send_sub_device_command(device_id, sub_device_id, cmd, params=None)` - convenience wrapper over `send_command` with `target` already set.
91
+ - `list_schedules(device_id)` / `get_schedule_context(device_id)` - a device's active schedules, and the commands available to schedule.
92
+ - `create_schedule(device_id, slot, on_ts, off_ts, ...)` / `update_schedule(...)` / `delete_schedule(device_id, slot)` - manage a device's schedule slots (0-4).
93
+ - `get_schedule_history(device_id)` - a device's completed or cancelled schedules, most recent first.
94
+
95
+ Full parameter/return types for every method: **[SDK Reference](https://docs.nexalware.com/docs/sdk/sdk-python)**.
96
+
97
+ ## Links
98
+
99
+ - [Docs](https://docs.nexalware.com)
100
+ - [Device Orchestration](https://docs.nexalware.com/docs/device-orchestration) - sub-devices, and the contract a master implements to report them.
101
+ - [Authentication & Access](https://docs.nexalware.com/docs/concepts/authentication-and-access) - API keys and DeviceGrants.
102
+
103
+ ## License
104
+
105
+ MIT
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/nexalware/__init__.py
5
+ src/nexalware/client.py
6
+ src/nexalware/errors.py
7
+ src/nexalware/py.typed
8
+ src/nexalware/types.py
9
+ src/nexalware.egg-info/PKG-INFO
10
+ src/nexalware.egg-info/SOURCES.txt
11
+ src/nexalware.egg-info/dependency_links.txt
12
+ src/nexalware.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ nexalware