├── .flake8 ├── .github └── workflows │ └── tox.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── README.md ├── requirements.txt └── time-series.py ├── mypy.ini ├── onboard ├── __init__.py └── client │ ├── __init__.py │ ├── client.py │ ├── dataframes.py │ ├── exceptions.py │ ├── helpers.py │ ├── models.py │ ├── py.typed │ ├── staging.py │ └── util.py ├── requirements.txt ├── scripts └── publish ├── setup.py ├── test-requirements.txt ├── tests ├── __init__.py └── test_models.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 99 3 | exclude = 4 | .git, 5 | .tox, 6 | build, 7 | dist, 8 | .mypy_cache, 9 | .pytest_cache, 10 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: onboard.client 2 | on: 3 | push: 4 | branches: dev 5 | pull_request: 6 | jobs: 7 | tox: 8 | runs-on: [ubuntu-22.04] 9 | strategy: 10 | matrix: 11 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 12 | name: Python ${{ matrix.python-version }} 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | architecture: x64 20 | - name: Install tox 21 | run: pip install tox 22 | - name: Stylecheck (flake8) 23 | run: tox -e flake8 24 | - name: Test (pytest) 25 | run: tox -e py3 26 | - name: Typecheck (mypy) 27 | run: tox -e mypy 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # test 2 | .pytest_cache/ 3 | 4 | # config 5 | /config.json 6 | !ansible/configs/*.ini 7 | 8 | # ignore ansile retry file 9 | site.retry 10 | 11 | #pycharm 12 | .idea 13 | 14 | # Byte-compiled / optimized / DLL files 15 | __pycache__/ 16 | *.py[cod] 17 | *$py.class 18 | 19 | # C extensions 20 | *.so 21 | 22 | # Distribution / packaging 23 | .Python 24 | build/ 25 | develop-eggs/ 26 | dist/ 27 | static/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | MANIFEST 41 | 42 | # PyInstaller 43 | # Usually these files are written by a python script from a template 44 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 45 | *.manifest 46 | *.spec 47 | 48 | # Installer logs 49 | pip-log.txt 50 | pip-delete-this-directory.txt 51 | 52 | # Unit test / coverage reports 53 | htmlcov/ 54 | .tox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | .hypothesis/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | .static_storage/ 70 | .media/ 71 | local_settings.py 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Onboard Portal Python SDK 2 | 3 | ![PyPI](https://img.shields.io/pypi/v/onboard.client) 4 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/onboard.client) 5 | ![PyPI - Status](https://img.shields.io/pypi/status/onboard.client) 6 | ![PyPI - License](https://img.shields.io/pypi/l/onboard.client) 7 | 8 | This package provides Python bindings to Onboard Data's [building data API](https://portal.onboarddata.io). 9 | For more details, you can navigate to the API & Docs Page to read the Getting Started Documentation section in the portal. 10 | You'll have access to this page once you've signed up for our sandbox or you've been given access to your organization's account. 11 | 12 | ## API Access 13 | 14 | You'll need an API key or existing account in order to use this client. If you don't have one and would like to start prototyping against an example building please [request a key here](https://onboarddata.io/sandbox). 15 | 16 | Once you have a key, data access is explicitly granted by attaching one or more 'scopes' to the key. Our endpoints are grouped by scope on the [swagger documentation](https://api.onboarddata.io/swagger/) viewer. 17 | 18 | You can also learn more about this client [on our docs!](https://onboard-api-wrappers-documentation.readthedocs.io/en/latest/index.html) 19 | 20 | ## Client usage example 21 | 22 | First, you'll need to install the client (requires Python >= `3.8`) 23 | 24 | ```bash 25 | $ pip install onboard.client 26 | ``` 27 | 28 | Now you can use the client to fetch timeseries data for sensors by building or based on type. This example requires an API key with the scopes `general` and `buildings:read`. 29 | 30 | ```python 31 | from onboard.client import OnboardClient 32 | client = OnboardClient(api_key='ob-p-your-key-here') 33 | 34 | client.whoami() # verify access & connectivity 35 | 36 | client.get_all_point_types() # retrieve available types of sensors 37 | 38 | # retrieve the past 6 hours of data for sensors measuring CO2 ppm 39 | from datetime import datetime, timezone, timedelta 40 | from onboard.client.models import PointSelector, TimeseriesQuery, PointData 41 | from typing import List 42 | 43 | query = PointSelector() 44 | query.point_types = ['Zone Carbon Dioxide'] 45 | query.buildings = ['Office Building'] # one of the example buildings available in the sandbox 46 | selection = client.select_points(query) 47 | end = datetime.utcnow().replace(tzinfo=timezone.utc) 48 | start = end - timedelta(hours=6) 49 | 50 | timeseries_query = TimeseriesQuery(point_ids=selection['points'], start=start, end=end) # Or `TimeseriesQuery(selector=query, ...)` 51 | 52 | sensor_metadata = client.get_points_by_ids(selection['points']) 53 | sensor_data: List[PointData] = list(client.stream_point_timeseries(timeseries_query)) 54 | ``` 55 | 56 | ### Retries 57 | The OnboardClient also exposes urllib3.util.retry.Retry to allow configuring retries in the event of a network issue. An example for use would be 58 | 59 | ```python 60 | from onboard.client import OnboardClient 61 | from urlilib3.util.retry import Retry 62 | 63 | retry = Retry(total=3, backoff_factor=0.3, status_forcelist=(500, 502, 504)) 64 | client = OnboardClient(api_key='ob-p-your-key-here', retry=retry) 65 | 66 | ``` 67 | 68 | ## Staging client usage 69 | 70 | We provide an additional client object for users who wish to modify their building equipment and points in the "staging area" before those metadata are promoted to the primary tables. API keys used with the staging client require the `staging` scope, and your account must be authorized to perform `READ` and `UPDATE` operations on the building itself. 71 | 72 | The staging area provides a scratchpad interface, where arbitrary additional columns can be added to points or equipment by using the prefix `p.` or `e.`. Any un-prefixed, user-added columns will be attached to points. Each update dictionary can modify a point, equipment or both at the same time (which implicitly reparents the point to the equipment). Each update should `p.topic` and/or `e.equip_id` to identify to the system where to apply the write. If a `topic` and `equip_id` are provided together in the same object then the system will associate that point with that equipment. 73 | 74 | Updates use `PATCH` semantics, so only provided fields will be written to. Check-and-set concurrency control is available. To use it, include a `.cas` field with a current value to verify before processing the update. Please see the `update` object in the example below for more details. 75 | 76 | The staging client supports the same urllib3.util.retry.Retry support that the standard client has. 77 | 78 | ```python 79 | from onboard.client.staging import OnboardStagingClient 80 | 81 | staging = OnboardStagingClient(api_key='ob-p-your-key-here') 82 | 83 | buildings = client.get_all_buildings() # using OnboardClient from above example 84 | all_building_details = staging.get_staging_building_details() # a list of building-level staging information objects 85 | 86 | building_id: int = 0 # yours here 87 | 88 | points = staging.get_staging_points(building_id) 89 | equipment = staging.get_staging_equipment(building_id) 90 | 91 | point_update = [ 92 | # keys to identify the point we are modifying 93 | { 94 | 'topic':'org/building/4242/my-sensor', 95 | # an update of related equipment: equip_names 96 | 'equip_names': ["equip_name1", "equip_name2"], 97 | # an optional check-and-set guard for the equip relation update 98 | 'modified.cas': '2025-03-13 14:44:24.133067+00:00' 99 | } 100 | ] 101 | equip_update = [ 102 | # keys to identify the equipment we are modifying 103 | { 104 | 'name':'ahu-1', 105 | # an update to rename equipment 106 | 'new_name': 'ahu-2', 107 | } 108 | ] 109 | modified_point = staging.update_staging_points(building_id, point_update) 110 | modified_equipment = staging.update_staging_equipment(building_id, equipment_update) 111 | ``` 112 | 113 | ## License 114 | 115 | Copyright 2018-2024 Onboard Data Inc 116 | 117 | Licensed under the Apache License, Version 2.0 (the "License"); 118 | you may not use this file except in compliance with the License. 119 | You may obtain a copy of the License at 120 | 121 | http://www.apache.org/licenses/LICENSE-2.0 122 | 123 | Unless required by applicable law or agreed to in writing, software 124 | distributed under the License is distributed on an "AS IS" BASIS, 125 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 126 | See the License for the specific language governing permissions and 127 | limitations under the License. 128 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # onboard sdk examples 2 | 3 | ## setup 4 | 5 | You should create a new vitualenv with python 3.6+ and install the requirements file in this directory: 6 | 7 | ``` 8 | pip install -r requirements.txt 9 | ``` 10 | 11 | ## time-series.py 12 | 13 | This example fetches timeseries data for sensors attached to a piece of equipment in a building 14 | -------------------------------------------------------------------------------- /examples/requirements.txt: -------------------------------------------------------------------------------- 1 | onboard.client 2 | numpy 3 | pandas 4 | -------------------------------------------------------------------------------- /examples/time-series.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from onboard.client import ProductionAPIClient 4 | from onboard.client.dataframes import points_df_from_timeseries 5 | 6 | 7 | def usage(): 8 | print("Usage:") 9 | print("python time-series.py " 10 | " ") 11 | print("e.g. python time-series.py \"Science Park\" \"AHU SSAC-1\" 2019-10-15 2019-10-31") 12 | sys.exit(1) 13 | 14 | 15 | def get_building_id(client, building): 16 | try: 17 | return int(building) 18 | except ValueError: 19 | pass # must have gotten a building name instead 20 | buildings = client.get_all_buildings() 21 | matches = [b.get('id') for b in buildings if b.get('name') == building] 22 | if not matches: 23 | return None 24 | if len(matches) > 1: 25 | print(f"Found multiple buildings named {building} - " 26 | f"ids = {matches} - please retry using an id") 27 | return None 28 | return matches[0] 29 | 30 | 31 | def get_equipment(client, building_id): 32 | all_equipment = client.get_building_equipment(building_id) 33 | for e in all_equipment: 34 | if e['suffix'] == equip_suffix: 35 | return e 36 | return None 37 | 38 | 39 | def fetch_time_series(api_key, building, equip_suffix, start_time, end_time): 40 | client = ProductionAPIClient(api_key=api_key) 41 | building_id = get_building_id(client, building) 42 | if building_id is None: 43 | print(f"Could not find a building named '{building}'") 44 | usage() 45 | 46 | equipment = get_equipment(client, building_id) 47 | if equipment is None: 48 | print(f"Could not find equipment with suffix '{equip_suffix}'") 49 | usage() 50 | 51 | point_ids = [p['id'] for p in equipment['points']] 52 | timeseries = client.query_point_timeseries(point_ids, start_time, end_time) 53 | 54 | return points_df_from_timeseries(timeseries, equipment['points']) 55 | 56 | 57 | if __name__ == '__main__': 58 | api_key = os.environ.get('ONBOARD_API_KEY') 59 | if api_key is None: 60 | print("API key must be set as environment variable ONBOARD_API_KEY") 61 | sys.exit(1) 62 | 63 | if len(sys.argv) < 5: 64 | usage() 65 | building = sys.argv[1] 66 | equipment = sys.argv[2] 67 | start = sys.argv[3] 68 | end = sys.argv[4] 69 | 70 | if not building or not equipment: 71 | usage() 72 | split = equipment.split(' ') 73 | if len(split) == 1: 74 | equip_suffix = split[0] 75 | else: 76 | equip_suffix = split[1] 77 | 78 | df = fetch_time_series(api_key, building, equip_suffix, start, end) 79 | 80 | print(df.head(5)) 81 | print(len(df)) 82 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | plugins = pydantic.mypy 3 | 4 | check_untyped_defs = True 5 | warn_unused_configs = True 6 | warn_return_any = False 7 | ignore_missing_imports = True 8 | 9 | [pydantic-mypy] 10 | init_typed = True 11 | warn_untyped_fields = True 12 | -------------------------------------------------------------------------------- /onboard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onboard-data/client-py/7a4c16a0a32420c1f092ec494f8223aaf3755e22/onboard/__init__.py -------------------------------------------------------------------------------- /onboard/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import APIClient, ProductionAPIClient, DevelopmentAPIClient # noqa: F401 2 | from .exceptions import OnboardApiException, OnboardTemporaryException 3 | 4 | OnboardClient = ProductionAPIClient 5 | 6 | __all__ = [ 7 | 'OnboardClient', 8 | 'APIClient', 9 | 'OnboardApiException', 10 | 'OnboardTemporaryException' 11 | ] 12 | -------------------------------------------------------------------------------- /onboard/client/client.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Dict, Any, Optional, Tuple, Union, Iterator 3 | 4 | import deprecation 5 | from orjson import loads 6 | from urllib3.util.retry import Retry 7 | 8 | from .exceptions import OnboardApiException 9 | from .helpers import ClientBase 10 | from .models import PointSelector, PointDataUpdate, IngestStats, \ 11 | TimeseriesQuery, PointData 12 | from .util import divide_chunks, json 13 | 14 | 15 | class APIClient(ClientBase): 16 | def __init__(self, 17 | api_url: str, 18 | user: Optional[str] = None, 19 | pw: Optional[str] = None, 20 | api_key: Optional[str] = None, 21 | token: Optional[str] = None, 22 | name: str = '', 23 | retry: Optional[Retry] = None, 24 | ) -> None: 25 | super().__init__(api_url, user, pw, api_key, token, name, retry) 26 | 27 | @json 28 | def whoami(self) -> Dict[str, str]: 29 | """returns the current account's information""" 30 | return self.get('/whoami') 31 | 32 | @json 33 | def get_account_actions(self) -> List[Dict[str, str]]: 34 | """returns the action audit log by or affecting the current account""" 35 | return self.get('/account-actions') 36 | 37 | @json 38 | def get_users(self) -> List[Dict[str, str]]: 39 | """returns the list of visible user accounts 40 | For organization admins this is all users in the organization 41 | For non-admin users this is just the current account 42 | """ 43 | return self.get('/users') 44 | 45 | @json 46 | def get_organizations(self) -> Dict[str, List[Dict[str, str]]]: 47 | return self.get('/organizations') 48 | 49 | @json 50 | def get_all_buildings(self) -> List[Dict[str, str]]: 51 | return self.get('/buildings') 52 | 53 | @json 54 | def get_tags(self) -> List[Dict[str, str]]: 55 | """returns a list of all the haystack tags in the system 56 | For more info, please see https://project-haystack.org/tag""" 57 | return self.get('/tags') 58 | 59 | @json 60 | def get_equipment_types(self) -> List[Dict[str, str]]: 61 | return self.get('/equiptype') 62 | 63 | @json 64 | def get_building_equipment(self, building_id: int) -> List[Dict[str, Any]]: 65 | return self.get(f'/buildings/{building_id}/equipment?points=true') 66 | 67 | @json 68 | def get_equipment_by_ids(self, equipment_ids: List[int]) -> List[Dict[str, object]]: 69 | body = {'equipment_ids': equipment_ids} 70 | return self.post('/equipment/query', json=body) 71 | 72 | @json 73 | def get_building_changelog(self, building_id: int) -> List[Dict[str, object]]: 74 | """Returns a list of changelog entries for the specified building""" 75 | return self.get(f'/buildings/{building_id}/changelog') 76 | 77 | @json 78 | def select_points(self, selector: PointSelector) -> Dict[str, List[int]]: 79 | """returns point ids based on the provided selector""" 80 | return self.post('/points/select', json=selector.json()) 81 | 82 | def check_data_availability(self, 83 | selector: PointSelector 84 | ) -> Tuple[Optional[datetime], Optional[datetime]]: 85 | """Returns a tuple of data timestamps (most stale, most recent) for selected points""" 86 | 87 | @json 88 | def get_as_json(): 89 | return self.post('/points/data-availability', json=selector.json()) 90 | 91 | res = get_as_json() 92 | oldest = self.ts_to_dt(res['oldest']) 93 | newest = self.ts_to_dt(res['newest']) 94 | return (oldest, newest) 95 | 96 | def get_all_points(self) -> List[Dict[str, Any]]: 97 | """returns all points for all visible buildings""" 98 | buildings = self.get_all_buildings() 99 | points: List[Dict[str, Any]] = [] 100 | for b in buildings: 101 | bldg_id = b['id'] 102 | equipment = self.get_building_equipment(bldg_id) 103 | for e in equipment: 104 | points += e['points'] 105 | return points 106 | 107 | def get_all_equipment(self) -> List[Dict]: 108 | """returns all equipment instances for all visible buildings""" 109 | buildings = self.get_all_buildings() 110 | equipment = [] 111 | for b in buildings: 112 | bldg_id = b['id'] 113 | equipment += self.get_building_equipment(bldg_id) 114 | return equipment 115 | 116 | def get_points_by_ids(self, point_ids: List[int]) -> List[Dict[str, str]]: 117 | @json 118 | def get_points(url): 119 | return self.get(url) 120 | 121 | points = [] 122 | for chunk in divide_chunks(point_ids, 500): 123 | points_str = '[' + ','.join(str(id) for id in chunk) + ']' 124 | url = f'/points?point_ids={points_str}' 125 | try: 126 | points_chunk = get_points(url) 127 | except OnboardApiException as e: 128 | if '"status": 404' in str(e): 129 | continue 130 | raise e 131 | points += points_chunk 132 | return points 133 | 134 | @json 135 | def get_all_point_types(self) -> List[Dict[str, str]]: 136 | return self.get('/pointtypes') 137 | 138 | @json 139 | def get_all_measurements(self) -> List[Dict[str, str]]: 140 | return self.get('/measurements') 141 | 142 | @json 143 | def get_all_units(self) -> List[Dict[str, str]]: 144 | return self.get('/unit') 145 | 146 | @deprecation.deprecated(deprecated_in="1.3.0", 147 | details="Use stream_point_timeseries instead") 148 | @json 149 | def query_point_timeseries(self, point_ids: List[int], 150 | start_time: Union[str, datetime], 151 | end_time: Union[str, datetime]) -> List[Dict[str, Any]]: 152 | """Query a timespan for a set of point ids 153 | point_ids: a list of point ids 154 | start/end time: ISO formatted timestamp strings e.g. '2019-11-29T20:16:25Z' or a datetime 155 | """ 156 | query = { 157 | 'point_ids': point_ids, 158 | 'start_time': self.dt_to_str(start_time), 159 | 'end_time': self.dt_to_str(end_time), 160 | } 161 | return self.post('/query', json=query) 162 | 163 | def stream_point_timeseries(self, query: TimeseriesQuery) -> Iterator[PointData]: 164 | """Query a time interval for an explicit set of point ids or 165 | with a selector which describes which sensors to include. 166 | 167 | Example values documented on the model tab here: 168 | https://api.onboarddata.io/doc/#/buildings%3Aread/post_query_v2 169 | """ 170 | 171 | @json 172 | def query_call(): 173 | return self.post('/query-v2', json=query.json(), stream=True, 174 | headers={'Accept': 'application/x-ndjson'}) 175 | 176 | query_call.raw_response = True # type: ignore[attr-defined] 177 | 178 | try: 179 | # Pydantic v1 180 | point_data = PointData.__pydantic_model__.construct # type: ignore[attr-defined] 181 | except AttributeError: 182 | # Pydantic v2 183 | point_data = PointData.model_construct # type: ignore[attr-defined] 184 | 185 | with query_call() as res: 186 | for line in res.iter_lines(chunk_size=20 * 1024): 187 | parsed = loads(line) 188 | yield point_data(**parsed) 189 | 190 | @json 191 | def update_point_data(self, updates: List[PointDataUpdate] = []) -> None: 192 | """Bulk update point data, returns the number of updated points 193 | updates: an iterable of models.PointDataUpdate objects""" 194 | for batch in divide_chunks(updates, 500): 195 | json = [u.json() for u in batch] 196 | self.post('/points_update', json=json).raise_for_status() 197 | 198 | @json 199 | def send_ingest_stats(self, ingest_stats: IngestStats) -> None: 200 | """Send timing and diagnostic info to the portal 201 | ingest_stats: an instance of models.IngestStats""" 202 | json = ingest_stats.json() 203 | self.post('/ingest-stats', json=json).raise_for_status() 204 | 205 | @json 206 | def get_ingest_stats(self) -> List[Dict[str, str]]: 207 | """returns ingest stats for all buildings""" 208 | return self.get('/ingest-stats') 209 | 210 | @json 211 | def get_alerts(self) -> List[Dict[str, str]]: 212 | """returns a list of active alerts for all buildings""" 213 | return self.get('/alerts') 214 | 215 | @json 216 | def copy_point_data(self, point_id_map: Dict[int, int], 217 | start_time: Union[str, datetime], 218 | end_time: Union[str, datetime]) -> str: 219 | """Copy data between points 220 | point_id_map: a map of source to destination point id 221 | start/end: ISO formatted timestamp strings e.g. '2019-11-29T20:16:25Z' 222 | returns: a string describing the operation 223 | """ 224 | command = { 225 | 'point_id_map': point_id_map, 226 | 'start_time': self.dt_to_str(start_time), 227 | 'end_time': self.dt_to_str(end_time), 228 | } 229 | return self.post('/point-data-copy', json=command) 230 | 231 | 232 | class DevelopmentAPIClient(APIClient): 233 | def __init__(self, 234 | user: Optional[str] = None, 235 | pw: Optional[str] = None, 236 | api_key: Optional[str] = None, 237 | token: Optional[str] = None, 238 | retry: Optional[Retry] = None, 239 | ) -> None: 240 | super().__init__('https://devapi.onboarddata.io', user, pw, api_key, token, retry=retry) 241 | 242 | 243 | class ProductionAPIClient(APIClient): 244 | def __init__(self, 245 | user: Optional[str] = None, 246 | pw: Optional[str] = None, 247 | api_key: Optional[str] = None, 248 | token: Optional[str] = None, 249 | retry: Optional[Retry] = None, 250 | ) -> None: 251 | super().__init__('https://api.onboarddata.io', user, pw, api_key, token, retry=retry) 252 | -------------------------------------------------------------------------------- /onboard/client/dataframes.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Dict, List, Union 2 | 3 | import pandas as pd 4 | 5 | from onboard.client.models import PointData 6 | 7 | 8 | def points_df_from_timeseries(timeseries, points=[]) -> pd.DataFrame: 9 | """Returns a pandas dataframe from the results of a timeseries query""" 10 | 11 | # 'type' is from the point_type.display_name column 12 | point_names = {str(p['id']): p.get('type') for p in points} 13 | 14 | columns = ['timestamp'] 15 | dates = set() 16 | data_by_point = {} 17 | 18 | for point in timeseries: 19 | point_id = point['tags']['point_id'] 20 | columns.append(point_id) 21 | col_indexes = point['columns'] 22 | 23 | ts_index = col_indexes.index('time') 24 | clean_index = col_indexes.index('clean') 25 | 26 | point_data: Dict[str, float] = {} 27 | data_by_point[point_id] = point_data 28 | 29 | for val in point['values']: 30 | ts = val[ts_index] 31 | dates.add(ts) 32 | clean = val[clean_index] 33 | point_data[ts] = clean 34 | 35 | sorted_dates = list(dates) 36 | sorted_dates.sort() 37 | data = [] 38 | 39 | for d in sorted_dates: 40 | row = {'timestamp': d} 41 | for p in columns[1:]: 42 | val = data_by_point[p].get(d) 43 | point_name = point_names.get(p) 44 | point_col = f"{point_name} - {p}" if point_name else p 45 | row[point_col] = val 46 | data.append(row) 47 | 48 | df = pd.DataFrame(data) 49 | return df 50 | 51 | 52 | def points_df_from_streaming_timeseries(timeseries: Iterable[PointData], 53 | points=[], 54 | point_column_label=None, 55 | ) -> pd.DataFrame: 56 | """Returns a pandas dataframe from the results of a timeseries query""" 57 | if point_column_label is None: 58 | def point_column_label(p): 59 | return p.get('id') 60 | 61 | point_names = {p['id']: point_column_label(p) for p in points} 62 | columns: List[Union[str, int]] = ['timestamp'] 63 | dates = set() 64 | data_by_point = {} 65 | 66 | for point in timeseries: 67 | columns.append(point.point_id) 68 | ts_index = point.columns.index('time') 69 | data_index = point.columns.index(point.unit) 70 | 71 | point_data: Dict[str, Union[str, float, None]] = {} 72 | data_by_point[point.point_id] = point_data 73 | 74 | for val in point.values: 75 | ts: str = val[ts_index] # type: ignore[assignment] 76 | dates.add(ts) 77 | clean = val[data_index] 78 | point_data[ts] = clean 79 | 80 | sorted_dates = list(dates) 81 | sorted_dates.sort() 82 | data = [] 83 | 84 | for d in sorted_dates: 85 | row = {'timestamp': d} 86 | for p in columns[1:]: 87 | val = data_by_point[p].get(d) # type: ignore 88 | point_col = point_names.get(p, p) 89 | row[point_col] = val # type: ignore 90 | data.append(row) 91 | 92 | df = pd.DataFrame(data) 93 | return df 94 | 95 | 96 | def df_time_index(df: pd.DataFrame, 97 | time_col='timestamp', utc=True) -> pd.DataFrame: 98 | dt_series = pd.to_datetime(df[time_col], infer_datetime_format=True) 99 | datetime_index = pd.DatetimeIndex(dt_series.values) 100 | if utc: 101 | datetime_index = datetime_index.tz_localize('UTC') 102 | df_indexed = df.set_index(datetime_index) 103 | df_indexed.drop(time_col, axis=1, inplace=True) 104 | return df_indexed 105 | 106 | 107 | def df_objs_to_numeric(df: pd.DataFrame): 108 | cols = df.columns[df.dtypes.eq('object')] 109 | return df[cols].apply(pd.to_numeric, errors='coerce') 110 | -------------------------------------------------------------------------------- /onboard/client/exceptions.py: -------------------------------------------------------------------------------- 1 | class OnboardApiException(Exception): 2 | """Wrapper for exceptions throw by the API client""" 3 | pass 4 | 5 | 6 | class OnboardTemporaryException(OnboardApiException): 7 | """These exceptions indicate that a call failed in a temporary manner 8 | and should be retried 9 | """ 10 | pass 11 | -------------------------------------------------------------------------------- /onboard/client/helpers.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Optional, Union, Any 3 | 4 | import requests 5 | from requests.adapters import HTTPAdapter 6 | from urllib3.util.retry import Retry 7 | 8 | from .exceptions import OnboardApiException 9 | from .util import json 10 | 11 | USER_AGENT = 'Onboard Py-SDK' 12 | 13 | 14 | class ClientBase: 15 | """Base class that implements HTTP methods against the API on top of requests""" 16 | 17 | def __init__(self, api_url: str, 18 | user: Optional[str], pw: Optional[str], 19 | api_key: Optional[str], 20 | token: Optional[str], 21 | name: Optional[str], 22 | retry: Optional[Retry], 23 | ) -> None: 24 | self.api_url = api_url 25 | self.api_key = api_key 26 | self.user = user 27 | self.pw = pw 28 | self.token = token 29 | self.name = name 30 | self.retry = retry 31 | if not (api_key or token or (user and pw)): 32 | raise OnboardApiException("Need one of: user & pw, token or api_key") 33 | self.session: Optional[requests.Session] = None 34 | 35 | def __session(self): 36 | if self.session is None: 37 | self.session = requests.Session() 38 | self.session.headers.update(self.headers()) 39 | self.session.headers.update(self.auth()) 40 | if self.retry: 41 | # http adapter is probably superfluous but no harm as a 'just in case' 42 | self.session.mount('http://', HTTPAdapter(max_retries=self.retry)) 43 | self.session.mount('https://', HTTPAdapter(max_retries=self.retry)) 44 | return self.session 45 | 46 | def headers(self): 47 | agent = f"{USER_AGENT} ({self.name})" if self.name else USER_AGENT 48 | return {'Content-Type': 'application/json', 49 | 'User-Agent': agent} 50 | 51 | def auth(self): 52 | if self.api_key is not None: 53 | return {'X-OB-Api': self.api_key} 54 | token = self.__get_token() 55 | return {'Authorization': f'Bearer {token}'} 56 | 57 | @json 58 | def __pw_login(self): 59 | payload = { 60 | 'login': self.user, 61 | 'password': self.pw, 62 | } 63 | return self.post('/login', json=payload) 64 | 65 | def __get_token(self): 66 | if self.token is None: 67 | login_res = self.__pw_login() 68 | self.token = login_res['access_token'] 69 | 70 | if self.token is None: 71 | raise OnboardApiException("Not authorized") 72 | 73 | return self.token 74 | 75 | def __repr__(self) -> str: 76 | return f"OnboardSdk(url={self.api_url})" 77 | 78 | def url(self, url: str) -> str: 79 | if not url.startswith('http'): 80 | return self.api_url + url 81 | return url 82 | 83 | # see comment in util about why we have to lie about types in order to make the 84 | # client as readable as possible 85 | # same idea here: each of these methods actually returns request.Response 86 | 87 | def get(self, url: str, **kwargs) -> Any: 88 | return self.__session().get(self.url(url), **kwargs) 89 | 90 | def delete(self, url: str, **kwargs) -> Any: 91 | return self.__session().delete(self.url(url), **kwargs) 92 | 93 | def put(self, url: str, **kwargs) -> Any: 94 | return self.__session().put(self.url(url), **kwargs) 95 | 96 | def post(self, url: str, **kwargs) -> Any: 97 | return self.__session().post(self.url(url), **kwargs) 98 | 99 | def patch(self, url: str, **kwargs) -> Any: 100 | return self.__session().patch(self.url(url), **kwargs) 101 | 102 | def ts_to_dt(self, ts: Optional[float]) -> Optional[datetime.datetime]: 103 | if ts is None: 104 | return None 105 | return datetime.datetime.utcfromtimestamp(ts / 1000.0) 106 | 107 | def dt_to_str(self, dt: Union[str, datetime.datetime]) -> str: 108 | if isinstance(dt, str): 109 | return dt 110 | if dt.tzinfo is None: 111 | return dt.isoformat() + "Z" 112 | return dt.isoformat() 113 | -------------------------------------------------------------------------------- /onboard/client/models.py: -------------------------------------------------------------------------------- 1 | import math 2 | from dataclasses import field 3 | from datetime import datetime, timezone 4 | from typing import List, Optional, Union, Dict 5 | 6 | from pydantic import field_validator, ConfigDict, BaseModel 7 | 8 | 9 | class PointDataUpdate(object): 10 | """Model for bulk-updating a point's data value and timestamp""" 11 | __slots__ = ['point_id', 'value', 'last_updated', 'first_updated'] 12 | 13 | def __init__(self, point_id: int, value: Union[str, float, int], 14 | last_updated: datetime, first_updated: Optional[datetime] = None) -> None: 15 | errors: List[str] = [] 16 | if not isinstance(point_id, int): 17 | errors.append(f"point id must be an integer, saw {point_id}") 18 | self.point_id = point_id 19 | self.value = value 20 | if not isinstance(last_updated, datetime): 21 | errors.append(f"last updated must be a datetime, saw {last_updated}") 22 | self.last_updated = last_updated 23 | if first_updated is not None and not isinstance(first_updated, datetime): 24 | errors.append(f"first updated must be a datetime, saw {first_updated}") 25 | self.first_updated = first_updated 26 | if errors: 27 | raise ValueError(f"Invalid PointDataUpdate: {', '.join(errors)}") 28 | 29 | def json(self): 30 | utc_ts_s = self.last_updated.replace(tzinfo=timezone.utc).timestamp() 31 | first_ts = None 32 | if self.first_updated is not None: 33 | first_ts = 1000 * self.first_updated.replace(tzinfo=timezone.utc).timestamp() 34 | return {'id': self.point_id, 'value': self.value, 35 | 'last_updated': utc_ts_s * 1000, 'first_updated': first_ts} 36 | 37 | 38 | class IngestStats(object): 39 | def __init__(self): 40 | self._points = [] 41 | self._building = {} 42 | 43 | def summary(self, info): 44 | # infos, errors, num_points, sample_points, etc 45 | for k, v in info.items(): 46 | if k == 'elapsed': 47 | self.elapsed(v) 48 | else: 49 | self._building[k] = v 50 | 51 | def add_points(self, points): 52 | self._points += points 53 | 54 | def elapsed(self, elapsed): 55 | self._building['processing_time_ms'] = math.floor(elapsed.total_seconds() * 1000) 56 | 57 | def json(self): 58 | return { 59 | 'building': self._building, 60 | 'points': self._points, 61 | } 62 | 63 | 64 | class PointSelector(BaseModel): 65 | """A flexible interface to allow users to select sets of points""" 66 | # id, name, short_name or name_abbr 67 | orgs: List[Union[int, str]] = field(default_factory=list) 68 | # id or name 69 | buildings: List[Union[int, str]] = field(default_factory=list) 70 | 71 | # returned points are the superset of these three selectors 72 | point_ids: List[int] = field(default_factory=list) 73 | point_names: List[str] = field(default_factory=list) 74 | point_topics: List[str] = field(default_factory=list) 75 | 76 | # allow filtering out points w/o recent data 77 | updated_since: Optional[datetime] = None 78 | 79 | # PointType.id or PointType.tag_name 80 | point_types: List[Union[int, str]] = field(default_factory=list) 81 | 82 | # Equipment.id or Equipment.suffix 83 | equipment: List[Union[int, str]] = field(default_factory=list) 84 | # EquipmentType.id or EquipmentType.tag_name 85 | equipment_types: List[Union[int, str]] = field(default_factory=list) 86 | 87 | def json(self): 88 | ts = self.updated_since.timestamp() * 1000.0 if self.updated_since is not None else None 89 | dict = {k: getattr(self, k) for k in vars(self) 90 | if not k.startswith('__')} 91 | return {**dict, 'updated_since': ts} 92 | 93 | @staticmethod 94 | def from_json(dict): 95 | ps = PointSelector() 96 | for k in ps.__dict__.keys(): 97 | val = dict.get(k, []) 98 | if k == 'updated_since': 99 | val = dict.get(k) 100 | if val is not None: 101 | val = datetime.fromtimestamp(val / 1000.0) 102 | setattr(ps, k, val) 103 | return ps 104 | 105 | 106 | class TimeseriesQuery(BaseModel): 107 | """Parameters needed to fetch timeseries data. 108 | 109 | Exactly one of point_ids or selector is required 110 | 111 | Note: the server may perform additional validation and reject queries 112 | which are constructable by the client 113 | 114 | For example values please refer to 115 | https://api.onboarddata.io/doc/#/buildings%3Aread/post_query_v2 116 | 117 | Unit conversion preferences are expressed as a map from measurement name to unit name, e.g. 118 | {'temperature': 'f', 'power': 'kw'} 119 | 120 | See https://portal.onboarddata.io/account?tab=unitPrefs for available measurements and units 121 | """ 122 | start: datetime # timezone required 123 | end: datetime # timezone required 124 | selector: Optional[PointSelector] = None 125 | point_ids: List[int] = field(default_factory=list) 126 | units: Dict[str, str] = field(default_factory=dict) # unit conversion preferences 127 | 128 | @field_validator('point_ids') 129 | def points_or_selector_required(cls, point_ids, values): 130 | has_points = len(point_ids) > 0 131 | has_selector = values.data.get('selector') is not None 132 | if has_points == has_selector: 133 | raise ValueError("Exactly one of 'point_ids' or 'selector' is required") 134 | return point_ids 135 | 136 | @field_validator('start', 'end') 137 | def times_valid(cls, value, values): 138 | if value.tzinfo is None: 139 | raise ValueError(f'Time boundaries require a timezone, saw: {value}') 140 | return value 141 | 142 | def json(self): 143 | return { 144 | 'start': self.start.timestamp(), 145 | 'end': self.end.timestamp(), 146 | 'selector': self.selector.json() if self.selector is not None else None, 147 | 'point_ids': self.point_ids, 148 | 'units': self.units, 149 | } 150 | 151 | 152 | class PointData(BaseModel): 153 | model_config = ConfigDict(extra='allow') 154 | 155 | point_id: int 156 | raw: str 157 | unit: str 158 | columns: List[str] 159 | values: List[List[Union[str, float, int, None]]] 160 | -------------------------------------------------------------------------------- /onboard/client/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onboard-data/client-py/7a4c16a0a32420c1f092ec494f8223aaf3755e22/onboard/client/py.typed -------------------------------------------------------------------------------- /onboard/client/staging.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Any, Optional, Tuple 2 | 3 | import deprecation 4 | from urllib3.util.retry import Retry 5 | 6 | from .helpers import ClientBase 7 | from .util import json 8 | 9 | 10 | class StagingClient(ClientBase): 11 | """Staging area API bindings""" 12 | 13 | def __init__(self, api_url: str, 14 | api_key: Optional[str] = None, 15 | token: Optional[str] = None, 16 | name: str = '', 17 | retry: Optional[Retry] = None, 18 | ) -> None: 19 | super().__init__(api_url, user=None, pw=None, api_key=api_key, token=token, name=name, 20 | retry=retry) 21 | 22 | @json 23 | def get_staging_building_details(self) -> List[Dict]: 24 | """Fetch building-level details for all buildings in staging""" 25 | return self.get('/staging') 26 | 27 | @json 28 | def update_building_details(self, building_id: int, 29 | details: Dict[str, Any]) -> Dict: 30 | """Update building-level details for a building in staging""" 31 | return self.patch(f"/staging/{building_id}/details", json=details) 32 | 33 | @json 34 | @deprecation.deprecated(deprecated_in="1.14.0", details="Replaced with get_staging_equipment") 35 | def get_staged_equipment(self, building_id: int) -> Dict: 36 | """Fetch staging equipment as Python objects""" 37 | return self.get(f'/staging/{building_id}') 38 | 39 | @json 40 | @deprecation.deprecated(deprecated_in="1.14.0", details="get_staging_points and " 41 | "get_staging_equipment can be used and" 42 | " results joined together as needed") 43 | def get_equipment_and_points(self, building_id: int) -> Dict: 44 | """Fetch staging equipment and point details together as Python objects""" 45 | return self.get(f'/staging/{building_id}?points=true') 46 | 47 | @json 48 | @deprecation.deprecated(deprecated_in="1.14.0", details="replaced with get_staging_points") 49 | def get_staged_points(self, building_id: int) -> Dict: 50 | """Fetch staging points as Python objects""" 51 | return self.get(f'/staging/{building_id}/points') 52 | 53 | @json 54 | @deprecation.deprecated(deprecated_in="1.14.0", details="replaced with get_staging_devices") 55 | def get_staged_devices(self, building_id: int) -> Dict: 56 | """Fetch staging devices as Python objects""" 57 | return self.get(f'/staging/{building_id}/devices') 58 | 59 | @json 60 | def get_staging_devices(self, building_id: int) -> List[Dict]: 61 | """Fetch staging devices as Python objects""" 62 | return self.get(f'/staging/{building_id}/devices') 63 | 64 | @json 65 | def get_staging_points(self, building_id: int) -> List[Dict]: 66 | """Fetch staging points as Python objects""" 67 | return self.get(f'/staging/{building_id}/points') 68 | 69 | @json 70 | def get_staging_equipment(self, building_id: int) -> List[Dict]: 71 | """Fetch staging equipment as Python objects""" 72 | return self.get(f'/staging/{building_id}/equipment') 73 | 74 | @deprecation.deprecated(deprecated_in="1.14.0", 75 | details="CSV paths removed, get_staging_points and " 76 | "get_staging_equipment can be used and results joined together" 77 | " as needed") 78 | def get_staged_equipment_csv(self, building_id: int) -> str: 79 | """Fetch staged equipment and points together in tabular form""" 80 | 81 | @json 82 | def get_csv(): 83 | return self.get(f'/staging/{building_id}', 84 | headers={'Accept': 'text/csv'}) 85 | 86 | get_csv.raw_response = True # type: ignore[attr-defined] 87 | return get_csv().text 88 | 89 | @json 90 | @deprecation.deprecated(deprecated_in="1.14.0", details="Use update_staging_equipment instead") 91 | def update_staged_equipment(self, building_id: int, updates: List[Dict]) -> Dict: 92 | """Update staged equipment and points""" 93 | return self.post(f'/staging/{building_id}', json=updates) 94 | 95 | @json 96 | def update_staging_devices(self, building_id: int, updates: List[Dict]) -> Dict: 97 | """Update staged equipment and points""" 98 | return self.patch(f'/staging/{building_id}/devices', json=updates) 99 | 100 | @json 101 | def update_staging_points(self, building_id: int, updates: List[Dict]) -> Dict: 102 | """Update staged equipment and points""" 103 | return self.patch(f'/staging/{building_id}/points', json=updates) 104 | 105 | @json 106 | def update_staging_equipment(self, building_id: int, updates: List[Dict]) -> Dict: 107 | """Update staged equipment and points""" 108 | return self.patch(f'/staging/{building_id}/equipment', json=updates) 109 | 110 | @json 111 | def validate_staging_building(self, building_id: int) -> Dict: 112 | """Validate staged equipment and points, returning any errors""" 113 | return self.get(f'/staging/{building_id}/validate') 114 | 115 | @json 116 | def promote_from_staging(self, building_id: int, 117 | equip_ids: List[str] = [], topics: List[str] = []) -> Dict: 118 | """Promote valid equipment and points to the primary tables, returning any errors 119 | If equip_ids or topics lists are non-empty then only promote those objects. Otherwise 120 | all valid objects are promoted.""" 121 | promote_req = {'equip_ids': equip_ids, 'topics': topics} 122 | return self.post(f'/staging/{building_id}/apply', json=promote_req) 123 | 124 | @json 125 | def unpromote_from_staging(self, 126 | building_id: int, 127 | equipment_ids: List[int] = [], point_ids: List[int] = [], 128 | equipment_point_pairs: List[Tuple[int, int]] = []) -> Dict: 129 | """Unpromote valid equipment, points, and their relationships to the primary tables, 130 | returning any errors. 131 | If equip_ids or topics lists are non-empty then only promote those objects. Otherwise 132 | all valid objects are promoted.""" 133 | promote_req = {'equipment_ids': equipment_ids, 'point_ids': point_ids, 134 | 'point_equipment_relationships': [ 135 | {'equipment_id': equipment_id, 'point_id': point_id} for 136 | equipment_id, point_id in equipment_point_pairs]} 137 | return self.delete(f'/staging/{building_id}/apply', json=promote_req) 138 | 139 | @json 140 | def delete_staging_equipment(self, building_id: int, equip_ids: List[str]) -> Dict: 141 | """Delete staged equipment, returning object describing deleted equipment""" 142 | return self.delete(f'/staging/{building_id}/equipment', json=equip_ids) 143 | 144 | 145 | class OnboardStagingClient(StagingClient): 146 | def __init__(self, api_key: str) -> None: 147 | super().__init__('https://api.onboarddata.io', api_key) 148 | -------------------------------------------------------------------------------- /onboard/client/util.py: -------------------------------------------------------------------------------- 1 | from typing import List, Iterable, TypeVar, Callable 2 | 3 | import requests 4 | 5 | from .exceptions import OnboardApiException, OnboardTemporaryException 6 | 7 | T = TypeVar('T') 8 | 9 | 10 | def divide_chunks(input_list: List[T], n: int) -> Iterable[List[T]]: 11 | # looping till length input_list 12 | for i in range(0, len(input_list), n): 13 | yield input_list[i:i + n] 14 | 15 | 16 | def json(func: Callable[..., T]) -> Callable[..., T]: 17 | """Decorator for making sure requests responses are handled consistently""" 18 | 19 | # the type annotations on json are a lie to let us type the methods in client 20 | # with approximate descriptions of the JSON they return, even though the methods 21 | # as implemented return requests.Response objects 22 | def wrapper(*args, **kwargs): 23 | try: 24 | res: requests.Response = func(*args, **kwargs) # type: ignore[assignment] 25 | if res is None: 26 | return None 27 | 28 | # remove the cached access token if authorization failed 29 | # it's likely just expired 30 | if res.status_code == 401 and args and args[0].token is not None: 31 | args[0].token = None 32 | return wrapper(*args, **kwargs) 33 | 34 | if res.status_code > 499: 35 | raise OnboardTemporaryException(res.text or res.status_code) 36 | if res.status_code > 399: 37 | raise OnboardApiException(res.text or res.status_code) 38 | 39 | if hasattr(wrapper, 'raw_response'): 40 | return res 41 | return res.json() 42 | except OnboardApiException as e: 43 | raise e 44 | except Exception as e: 45 | raise OnboardApiException(e) 46 | 47 | return wrapper 48 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | certifi>=2019.3.9 2 | chardet>=3.0.4 3 | idna>=2.8 4 | requests>=2.21.0 5 | urllib3>=1.24.2 6 | deprecation>=2.1.0 7 | pydantic>=2,<4 8 | orjson>=3.9.15 9 | -------------------------------------------------------------------------------- /scripts/publish: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # current script directory 5 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" 6 | cd "${DIR}/.." 7 | 8 | rm -rf dist/* 9 | 10 | python3 setup.py sdist bdist_wheel 11 | python3 -m twine upload dist/* 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open('requirements.txt') as f: 4 | requirements = f.read().splitlines() 5 | 6 | with open("README.md", "r") as f: 7 | long_description = f.read() 8 | 9 | setup(name='onboard_client', 10 | version='1.15.0', 11 | author='Nathan Merritt, John Vines', 12 | author_email='support@onboarddata.io', 13 | description='Onboard API SDK', 14 | long_description=long_description, 15 | long_description_content_type="text/markdown", 16 | url='https://github.com/onboard-data/client-py', 17 | packages=['onboard.client'], 18 | install_requires=requirements, 19 | package_data={ 20 | 'onboard.client': ['py.typed'], 21 | }, 22 | classifiers=[ 23 | 'Development Status :: 5 - Production/Stable', 24 | 'Environment :: Console', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: Apache Software License', 27 | 'Programming Language :: Python :: 3.8', 28 | 'Programming Language :: Python :: 3.9', 29 | 'Programming Language :: Python :: 3.10', 30 | 'Programming Language :: Python :: 3.11', 31 | 'Programming Language :: Python :: 3.12', 32 | 'Programming Language :: Python :: 3.13', 33 | 'Topic :: Scientific/Engineering :: Information Analysis', 34 | 'Topic :: Software Development :: Libraries', 35 | 'Topic :: Software Development :: Libraries :: Python Modules', 36 | ], 37 | python_requires='>=3.8', 38 | ) 39 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | types-requests 3 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onboard-data/client-py/7a4c16a0a32420c1f092ec494f8223aaf3755e22/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | from onboard.client.models import TimeseriesQuery, PointData 4 | 5 | 6 | def test_timeseries_query(): 7 | return TimeseriesQuery( 8 | point_ids=[1], 9 | start=datetime.utcnow().replace(tzinfo=timezone.utc), 10 | end=datetime.utcnow().replace(tzinfo=timezone.utc), 11 | ) 12 | 13 | 14 | def test_point_data(): 15 | PointData( 16 | point_id=1, 17 | raw='F', 18 | unit='C', 19 | columns=['timestamp', 'raw', 'C'], 20 | values=[ 21 | ['2020-12-16', 32.0, 0.0], 22 | ] 23 | ) 24 | 25 | 26 | def test_point_data_none_value(): 27 | PointData( 28 | point_id=1, 29 | raw='F', 30 | unit='C', 31 | columns=['timestamp', 'raw', 'C'], 32 | values=[ 33 | ['2020-12-16', None, 0.0], 34 | ] 35 | ) 36 | 37 | 38 | def test_point_data_extra_keys(): 39 | constructed = PointData( 40 | point_id=1, 41 | raw='F', 42 | unit='C', 43 | columns=['timestamp', 'raw', 'C'], 44 | values=[ 45 | ['2020-12-16', 32.0, 0.0], 46 | ], 47 | foo='bar', 48 | zip={'zap': 1}, 49 | ) 50 | assert constructed.foo == 'bar' # type: ignore[attr-defined] 51 | assert constructed.point_id == 1 52 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3,mypy,flake8 3 | skipsdist = True 4 | 5 | [testenv] 6 | deps = 7 | pytest 8 | -rrequirements.txt 9 | commands = pytest tests 10 | 11 | [testenv:mypy] 12 | basepython = python3 13 | deps = 14 | mypy==1.4.1 15 | -r requirements.txt 16 | -r test-requirements.txt 17 | commands = 18 | mypy onboard tests --show-traceback 19 | 20 | [testenv:flake8] 21 | basepython = python3 22 | skip_install = true 23 | deps = 24 | flake8 25 | flake8-breakpoint 26 | commands = flake8 27 | 28 | [flake8] 29 | ignore = D100,D101 30 | exclude = .git, .tox, build, dist 31 | max-line-length = 99 32 | --------------------------------------------------------------------------------