novant 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.
novant-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Novant LLC
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.
novant-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: novant
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Novant API
5
+ Author-email: Novant <support@novant.io>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://novant.io
8
+ Project-URL: Documentation, https://docs.novant.io/api
9
+ Project-URL: Repository, https://github.com/novant-io/novant-python
10
+ Keywords: novant,novant-sdk,iot,building-automation,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: pandas
22
+ Requires-Dist: pandas>=1.5; extra == "pandas"
23
+ Dynamic: license-file
24
+
25
+ # novant-python
26
+
27
+ Python SDK for the [Novant](https://novant.io) API.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install novant
33
+ ```
34
+
35
+ Or install from source:
36
+
37
+ ```bash
38
+ git clone https://github.com/novant-io/novant-python.git
39
+ cd novant-python
40
+ pip install -e .
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ from novant import NovantClient
47
+
48
+ client = NovantClient(api_key="ak_xxx")
49
+
50
+ # Get project info
51
+ proj = client.project()
52
+ print(proj.city) # "Richmond, VA"
53
+ print(proj.tz) # "New_York"
54
+
55
+ # List assets
56
+ for asset in client.assets():
57
+ print(asset.name, asset.type)
58
+
59
+ # Read current values
60
+ for v in client.values(source_id="s.2"):
61
+ print(v.id, v.val, v.status)
62
+
63
+ # Get trend data
64
+ for row in client.trends(point_ids=["s.2.4", "s.2.5"], date="2026-03-09"):
65
+ print(row.ts, row.values)
66
+ ```
67
+
68
+ ## Pandas & NumPy
69
+
70
+ Install with optional pandas support:
71
+
72
+ ```bash
73
+ pip install novant[pandas]
74
+ ```
75
+
76
+ Convert values and trends directly to DataFrames:
77
+
78
+ ```python
79
+ # Values as DataFrame
80
+ result = client.values(source_id="s.2")
81
+ df = result.to_dataframe()
82
+
83
+ # Trends as DataFrame with DatetimeIndex
84
+ trends = client.trends(point_ids=["s.2.4", "s.2.5"], date="2026-03-09")
85
+ df = trends.to_dataframe()
86
+
87
+ # Trends as NumPy array
88
+ arr = trends.to_numpy()
89
+ ```
90
+
91
+ ## API
92
+
93
+ ### Client
94
+
95
+ ```python
96
+ client = NovantClient(api_key="ak_xxx", timeout=30)
97
+ ```
98
+
99
+ Client arguments:
100
+
101
+ | Parameter | Default | Description |
102
+ |-----------|---------|-------------|
103
+ | `api_key` | — | API key string |
104
+ | `timeout` | `30` | Request timeout in seconds |
105
+
106
+ ### Methods
107
+
108
+ | Method | Returns | Description |
109
+ |--------|---------|-------------|
110
+ | `project()` | `Project` | Get project metadata |
111
+ | `assets(asset_ids=None)` | `AssetList` | List assets |
112
+ | `spaces(space_ids=None)` | `SpaceList` | List spaces |
113
+ | `zones(zone_ids=None)` | `ZoneList` | List zones |
114
+ | `sources(source_ids=None, bound_only=False)` | `SourceList` | List sources |
115
+ | `points(source_id=None, asset_id=None, point_ids=None)` | `PointList` | List points for a source or asset |
116
+ | `values(source_id=None, asset_id=None, point_ids=None)` | `ValueList` | Get current values |
117
+ | `trends(point_ids, ...)` | `TrendData` | Get historical trend data |
118
+ | `write(point_id, value, level=None)` | `WriteResult` | Write a value to a point |
119
+ | `import_zones(csv_data)` | `dict` | Import zones from CSV |
120
+ | `import_spaces(csv_data)` | `dict` | Import spaces from CSV |
121
+ | `import_assets(csv_data)` | `dict` | Import assets from CSV |
122
+ | `import_sources(csv_data)` | `dict` | Import sources from CSV |
123
+ | `import_source_map(csv_data)` | `dict` | Import source point mappings from CSV |
124
+ | `import_trends(csv_data, mode=None)` | `dict` | Import trend data from CSV |
125
+
126
+ See the [Novant API docs](https://docs.novant.io/api) for full parameter details.
127
+
128
+ ## Error Handling
129
+
130
+ API errors raise `NovantErr`:
131
+
132
+ ```python
133
+ from novant import NovantClient, NovantErr
134
+
135
+ client = NovantClient(api_key="ak_xxx")
136
+ try:
137
+ client.project()
138
+ except NovantErr as e:
139
+ print(e.code) # HTTP status code
140
+ print(e.message) # Error message
141
+ ```
novant-0.1.0/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # novant-python
2
+
3
+ Python SDK for the [Novant](https://novant.io) API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install novant
9
+ ```
10
+
11
+ Or install from source:
12
+
13
+ ```bash
14
+ git clone https://github.com/novant-io/novant-python.git
15
+ cd novant-python
16
+ pip install -e .
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```python
22
+ from novant import NovantClient
23
+
24
+ client = NovantClient(api_key="ak_xxx")
25
+
26
+ # Get project info
27
+ proj = client.project()
28
+ print(proj.city) # "Richmond, VA"
29
+ print(proj.tz) # "New_York"
30
+
31
+ # List assets
32
+ for asset in client.assets():
33
+ print(asset.name, asset.type)
34
+
35
+ # Read current values
36
+ for v in client.values(source_id="s.2"):
37
+ print(v.id, v.val, v.status)
38
+
39
+ # Get trend data
40
+ for row in client.trends(point_ids=["s.2.4", "s.2.5"], date="2026-03-09"):
41
+ print(row.ts, row.values)
42
+ ```
43
+
44
+ ## Pandas & NumPy
45
+
46
+ Install with optional pandas support:
47
+
48
+ ```bash
49
+ pip install novant[pandas]
50
+ ```
51
+
52
+ Convert values and trends directly to DataFrames:
53
+
54
+ ```python
55
+ # Values as DataFrame
56
+ result = client.values(source_id="s.2")
57
+ df = result.to_dataframe()
58
+
59
+ # Trends as DataFrame with DatetimeIndex
60
+ trends = client.trends(point_ids=["s.2.4", "s.2.5"], date="2026-03-09")
61
+ df = trends.to_dataframe()
62
+
63
+ # Trends as NumPy array
64
+ arr = trends.to_numpy()
65
+ ```
66
+
67
+ ## API
68
+
69
+ ### Client
70
+
71
+ ```python
72
+ client = NovantClient(api_key="ak_xxx", timeout=30)
73
+ ```
74
+
75
+ Client arguments:
76
+
77
+ | Parameter | Default | Description |
78
+ |-----------|---------|-------------|
79
+ | `api_key` | — | API key string |
80
+ | `timeout` | `30` | Request timeout in seconds |
81
+
82
+ ### Methods
83
+
84
+ | Method | Returns | Description |
85
+ |--------|---------|-------------|
86
+ | `project()` | `Project` | Get project metadata |
87
+ | `assets(asset_ids=None)` | `AssetList` | List assets |
88
+ | `spaces(space_ids=None)` | `SpaceList` | List spaces |
89
+ | `zones(zone_ids=None)` | `ZoneList` | List zones |
90
+ | `sources(source_ids=None, bound_only=False)` | `SourceList` | List sources |
91
+ | `points(source_id=None, asset_id=None, point_ids=None)` | `PointList` | List points for a source or asset |
92
+ | `values(source_id=None, asset_id=None, point_ids=None)` | `ValueList` | Get current values |
93
+ | `trends(point_ids, ...)` | `TrendData` | Get historical trend data |
94
+ | `write(point_id, value, level=None)` | `WriteResult` | Write a value to a point |
95
+ | `import_zones(csv_data)` | `dict` | Import zones from CSV |
96
+ | `import_spaces(csv_data)` | `dict` | Import spaces from CSV |
97
+ | `import_assets(csv_data)` | `dict` | Import assets from CSV |
98
+ | `import_sources(csv_data)` | `dict` | Import sources from CSV |
99
+ | `import_source_map(csv_data)` | `dict` | Import source point mappings from CSV |
100
+ | `import_trends(csv_data, mode=None)` | `dict` | Import trend data from CSV |
101
+
102
+ See the [Novant API docs](https://docs.novant.io/api) for full parameter details.
103
+
104
+ ## Error Handling
105
+
106
+ API errors raise `NovantErr`:
107
+
108
+ ```python
109
+ from novant import NovantClient, NovantErr
110
+
111
+ client = NovantClient(api_key="ak_xxx")
112
+ try:
113
+ client.project()
114
+ except NovantErr as e:
115
+ print(e.code) # HTTP status code
116
+ print(e.message) # Error message
117
+ ```
@@ -0,0 +1,16 @@
1
+ # Copyright (c) 2026, Novant LLC
2
+ # Licensed under the MIT License
3
+ #
4
+ # History:
5
+ # 9 Mar 2026 Andy Frank Creation
6
+ #
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ from .client import NovantClient
11
+ from .err import NovantErr
12
+ from .models import (
13
+ Project, Asset, AssetList, Space, SpaceList, Zone, ZoneList,
14
+ Source, SourceList, Point, PointList, EnumState, Ontology,
15
+ PointValue, ValueList, TrendRow, TrendData, WriteResult,
16
+ )
@@ -0,0 +1,372 @@
1
+ #
2
+ # Copyright (c) 2026, Novant LLC
3
+ # Licensed under the MIT License
4
+ #
5
+ # History:
6
+ # 9 Mar 2026 Andy Frank Creation
7
+ #
8
+
9
+ import json
10
+ import urllib.request
11
+ import urllib.parse
12
+ import urllib.error
13
+ import base64
14
+ import gzip
15
+
16
+ from . import __version__
17
+ from .err import NovantErr
18
+ from .models import (
19
+ Project, AssetList, SpaceList, ZoneList, SourceList,
20
+ PointList, ValueList, TrendData, WriteResult,
21
+ )
22
+
23
+ #############################################################################
24
+ # NovantClient
25
+ #############################################################################
26
+
27
+ class NovantClient:
28
+ """Client for the Novant REST API."""
29
+
30
+ def __init__(self, api_key, timeout=30):
31
+ """Create a new NovantClient instance.
32
+
33
+ Args:
34
+ api_key: API key string (e.g. 'ak_xxx')
35
+ timeout: request timeout in seconds (default 30)
36
+ """
37
+ self.api_key = api_key
38
+ self.base_url = "https://api.novant.io/v1"
39
+ self.timeout = timeout
40
+
41
+ ######
42
+ # Project
43
+ ######
44
+
45
+ def project(self):
46
+ """Get project metadata.
47
+
48
+ Returns:
49
+ Project
50
+ """
51
+ return Project._from_dict(self._get("/project"))
52
+
53
+ ######
54
+ # Assets
55
+ ######
56
+
57
+ def assets(self, asset_ids=None):
58
+ """List assets for this project.
59
+
60
+ Args:
61
+ asset_ids: optional list of asset id strings to filter
62
+
63
+ Returns:
64
+ AssetList
65
+ """
66
+ params = {}
67
+ if asset_ids is not None:
68
+ params["asset_ids"] = ",".join(asset_ids)
69
+ return AssetList._from_dict(self._get("/assets", params))
70
+
71
+ ######
72
+ # Spaces
73
+ ######
74
+
75
+ def spaces(self, space_ids=None):
76
+ """List spaces for this project.
77
+
78
+ Args:
79
+ space_ids: optional list of space id strings to filter
80
+
81
+ Returns:
82
+ SpaceList
83
+ """
84
+ params = {}
85
+ if space_ids is not None:
86
+ params["space_ids"] = ",".join(space_ids)
87
+ return SpaceList._from_dict(self._get("/spaces", params))
88
+
89
+ ######
90
+ # Zones
91
+ ######
92
+
93
+ def zones(self, zone_ids=None):
94
+ """List zones for this project.
95
+
96
+ Args:
97
+ zone_ids: optional list of zone id strings to filter
98
+
99
+ Returns:
100
+ ZoneList
101
+ """
102
+ params = {}
103
+ if zone_ids is not None:
104
+ params["zone_ids"] = ",".join(zone_ids)
105
+ return ZoneList._from_dict(self._get("/zones", params))
106
+
107
+ ######
108
+ # Sources
109
+ ######
110
+
111
+ def sources(self, source_ids=None, bound_only=False):
112
+ """List sources for this project.
113
+
114
+ Args:
115
+ source_ids: optional list of source id strings to filter
116
+ bound_only: if True only return bound sources
117
+
118
+ Returns:
119
+ SourceList
120
+ """
121
+ params = {}
122
+ if source_ids is not None:
123
+ params["source_ids"] = ",".join(source_ids)
124
+ if bound_only:
125
+ params["bound_only"] = "true"
126
+ return SourceList._from_dict(self._get("/sources", params))
127
+
128
+ ######
129
+ # Points
130
+ ######
131
+
132
+ def points(self, source_id=None, asset_id=None, point_ids=None):
133
+ """List points for a source or asset.
134
+
135
+ Args:
136
+ source_id: parent source id (one of source_id or asset_id required)
137
+ asset_id: parent asset id (one of source_id or asset_id required)
138
+ point_ids: optional list of point id strings to filter
139
+
140
+ Returns:
141
+ PointList
142
+ """
143
+ params = {}
144
+ if source_id is not None:
145
+ params["source_id"] = source_id
146
+ if asset_id is not None:
147
+ params["asset_id"] = asset_id
148
+ if point_ids is not None:
149
+ params["point_ids"] = ",".join(point_ids)
150
+ return PointList._from_dict(self._get("/points", params))
151
+
152
+ ######
153
+ # Values
154
+ ######
155
+
156
+ def values(self, source_id=None, asset_id=None, point_ids=None):
157
+ """Get current values for points.
158
+
159
+ Args:
160
+ source_id: parent source id (one of source_id or asset_id required)
161
+ asset_id: parent asset id (one of source_id or asset_id required)
162
+ point_ids: optional list of point id strings to filter
163
+
164
+ Returns:
165
+ ValueList
166
+ """
167
+ params = {}
168
+ if source_id is not None:
169
+ params["source_id"] = source_id
170
+ if asset_id is not None:
171
+ params["asset_id"] = asset_id
172
+ if point_ids is not None:
173
+ params["point_ids"] = ",".join(point_ids)
174
+ return ValueList._from_dict(self._get("/values", params))
175
+
176
+ ######
177
+ # Trends
178
+ ######
179
+
180
+ def trends(self, point_ids, start_date=None, end_date=None, date=None,
181
+ tz=None, interval=None, aggregate=None):
182
+ """Get historical trend data for points.
183
+
184
+ Args:
185
+ point_ids: list of point id strings
186
+ start_date: start date as YYYY-MM-DD (required unless date is set)
187
+ end_date: end date as YYYY-MM-DD (required unless date is set)
188
+ date: single date as YYYY-MM-DD (takes precedence over start/end)
189
+ tz: timezone for results (defaults to project timezone)
190
+ interval: resample interval - auto|5min|15min|30min|1hr|1day|1mo|raw
191
+ aggregate: aggregation function - auto|mean|sum|min|max|diff
192
+
193
+ Returns:
194
+ TrendData
195
+ """
196
+ params = {"point_ids": ",".join(point_ids)}
197
+ if date is not None:
198
+ params["date"] = date
199
+ else:
200
+ if start_date is not None:
201
+ params["start_date"] = start_date
202
+ if end_date is not None:
203
+ params["end_date"] = end_date
204
+ if tz is not None:
205
+ params["tz"] = tz
206
+ if interval is not None:
207
+ params["interval"] = interval
208
+ if aggregate is not None:
209
+ params["aggregate"] = aggregate
210
+ return TrendData._from_dict(self._get("/trends", params))
211
+
212
+ ######
213
+ # Write
214
+ ######
215
+
216
+ def write(self, point_id, value, level=None):
217
+ """Write a value to a point.
218
+
219
+ Args:
220
+ point_id: point id string
221
+ value: numeric value or None to clear priority hold
222
+ level: optional BACnet priority level (defaults to 16)
223
+
224
+ Returns:
225
+ WriteResult
226
+ """
227
+ params = {"point_id": point_id}
228
+ if value is None:
229
+ params["value"] = "null"
230
+ else:
231
+ params["value"] = str(value)
232
+ if level is not None:
233
+ params["level"] = str(level)
234
+ return WriteResult._from_dict(self._post("/write", params))
235
+
236
+ ######
237
+ # Import
238
+ ######
239
+
240
+ def import_zones(self, csv_data):
241
+ """Import zones from CSV data.
242
+
243
+ Args:
244
+ csv_data: CSV string content
245
+
246
+ Returns:
247
+ dict with status
248
+ """
249
+ return self._post_csv("/import/zones", csv_data)
250
+
251
+ def import_spaces(self, csv_data):
252
+ """Import spaces from CSV data.
253
+
254
+ Args:
255
+ csv_data: CSV string content
256
+
257
+ Returns:
258
+ dict with status
259
+ """
260
+ return self._post_csv("/import/spaces", csv_data)
261
+
262
+ def import_assets(self, csv_data):
263
+ """Import assets from CSV data.
264
+
265
+ Args:
266
+ csv_data: CSV string content
267
+
268
+ Returns:
269
+ dict with status
270
+ """
271
+ return self._post_csv("/import/assets", csv_data)
272
+
273
+ def import_sources(self, csv_data):
274
+ """Import sources from CSV data.
275
+
276
+ Args:
277
+ csv_data: CSV string content
278
+
279
+ Returns:
280
+ dict with status
281
+ """
282
+ return self._post_csv("/import/sources", csv_data)
283
+
284
+ def import_source_map(self, csv_data):
285
+ """Import source map from CSV data.
286
+
287
+ Args:
288
+ csv_data: CSV string content
289
+
290
+ Returns:
291
+ dict with status
292
+ """
293
+ return self._post_csv("/import/source-map", csv_data)
294
+
295
+ def import_trends(self, csv_data, mode=None):
296
+ """Import trend data from CSV data.
297
+
298
+ CSV must have 'ts' as the first column (ISO 8601 timestamps)
299
+ followed by point id columns. Max 50 columns and 10,000 rows.
300
+
301
+ Args:
302
+ csv_data: CSV string content
303
+ mode: merge (default), append, or prepend
304
+
305
+ Returns:
306
+ dict with status
307
+ """
308
+ params = {}
309
+ if mode is not None:
310
+ params["mode"] = mode
311
+ return self._post_csv("/import/trends", csv_data, params)
312
+
313
+ ##########################################################################
314
+ # Private
315
+ ##########################################################################
316
+
317
+ def _get(self, path, params=None):
318
+ """Perform a GET request."""
319
+ url = self.base_url + path
320
+ if params:
321
+ url += "?" + urllib.parse.urlencode(params)
322
+ req = urllib.request.Request(url)
323
+ self._add_headers(req)
324
+ return self._send(req)
325
+
326
+ def _post(self, path, params):
327
+ """Perform a POST request with form-encoded body."""
328
+ url = self.base_url + path
329
+ data = urllib.parse.urlencode(params).encode("utf-8")
330
+ req = urllib.request.Request(url, data=data, method="POST")
331
+ self._add_headers(req)
332
+ return self._send(req)
333
+
334
+ def _post_csv(self, path, csv_data, params=None):
335
+ """Perform a POST request with CSV body."""
336
+ url = self.base_url + path
337
+ if params:
338
+ url += "?" + urllib.parse.urlencode(params)
339
+ data = csv_data.encode("utf-8") if isinstance(csv_data, str) else csv_data
340
+ req = urllib.request.Request(url, data=data, method="POST")
341
+ req.add_header("Content-Type", "text/csv")
342
+ self._add_headers(req)
343
+ return self._send(req)
344
+
345
+ def _add_headers(self, req):
346
+ """Add auth, compression, and user-agent headers."""
347
+ creds = base64.b64encode(
348
+ (self.api_key + ":").encode("utf-8")
349
+ ).decode("utf-8")
350
+ req.add_header("Authorization", "Basic " + creds)
351
+ req.add_header("Accept-Encoding", "gzip")
352
+ req.add_header("User-Agent", "novant-python/" + __version__)
353
+
354
+ def _send(self, req):
355
+ """Send request and return parsed JSON response."""
356
+ try:
357
+ resp = urllib.request.urlopen(req, timeout=self.timeout)
358
+ return self._read_json(resp)
359
+ except urllib.error.HTTPError as e:
360
+ # attempt to parse error body
361
+ try:
362
+ body = self._read_json(e)
363
+ except Exception:
364
+ body = None
365
+ raise NovantErr(e.code, body) from None
366
+
367
+ def _read_json(self, resp):
368
+ """Read and decode a response body."""
369
+ raw = resp.read()
370
+ if resp.headers.get("Content-Encoding") == "gzip":
371
+ raw = gzip.decompress(raw)
372
+ return json.loads(raw.decode("utf-8"))
@@ -0,0 +1,23 @@
1
+ #
2
+ # Copyright (c) 2026, Novant LLC
3
+ # Licensed under the MIT License
4
+ #
5
+ # History:
6
+ # 9 Mar 2026 Andy Frank Creation
7
+ #
8
+
9
+ #############################################################################
10
+ # NovantErr
11
+ #############################################################################
12
+
13
+ class NovantErr(Exception):
14
+ """Error returned from the Novant API."""
15
+
16
+ def __init__(self, code, body=None):
17
+ self.code = code
18
+ self.body = body
19
+ if body and isinstance(body, dict):
20
+ self.message = body.get("message", body.get("error", str(body)))
21
+ else:
22
+ self.message = f"HTTP {code}"
23
+ super().__init__(self.message)