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 +21 -0
- novant-0.1.0/PKG-INFO +141 -0
- novant-0.1.0/README.md +117 -0
- novant-0.1.0/novant/__init__.py +16 -0
- novant-0.1.0/novant/client.py +372 -0
- novant-0.1.0/novant/err.py +23 -0
- novant-0.1.0/novant/models.py +571 -0
- novant-0.1.0/novant.egg-info/PKG-INFO +141 -0
- novant-0.1.0/novant.egg-info/SOURCES.txt +12 -0
- novant-0.1.0/novant.egg-info/dependency_links.txt +1 -0
- novant-0.1.0/novant.egg-info/requires.txt +3 -0
- novant-0.1.0/novant.egg-info/top_level.txt +1 -0
- novant-0.1.0/pyproject.toml +35 -0
- novant-0.1.0/setup.cfg +4 -0
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)
|