├── .github └── instructions │ └── instructions.md ├── .gitignore ├── .vscode └── tasks.json ├── LICENSE ├── README.md ├── pyproject.toml ├── pytest.ini ├── setup.py ├── tests ├── __init__.py ├── test_asset.py ├── test_attributes.py ├── test_customer.py ├── test_dashboard.py ├── test_device.py ├── test_device_profile.py ├── test_device_profile_info.py ├── test_telemetry.py ├── test_tenant.py └── test_user.py ├── thingsboard_api_tools.egg-info ├── PKG-INFO ├── SOURCES.txt ├── dependency_links.txt ├── not-zip-safe ├── requires.txt └── top_level.txt └── thingsboard_api_tools ├── Customer.py ├── Dashboard.py ├── Device.py ├── DeviceProfile.py ├── EntityType.py ├── HasAttributes.py ├── TbApi.py ├── TbModel.py ├── TelemetryRecord.py ├── Tenant.py ├── User.py ├── __init__.py ├── py.typed ├── pyproject.toml └── requirements.txt /.github/instructions/instructions.md: -------------------------------------------------------------------------------- 1 | --- 2 | applyTo: '*.py' 3 | --- 4 | Minimize chit-chat, be business like, answer directly. Don't suck up. 5 | Use double quotes for strings, and use f-strings where possible. 6 | Always provide 3.12 or higher type hints (i.e. list and dict are better than List and Dict). 7 | Comment style: whitespace before and after triple quotes. 8 | Please tell me if you read this file. 9 | 10 | If you need to run any python commands, be sure to activate the virtual environment, or call python in the .venv directory directly. 11 | When running anything involving `pip`, use `uv pip` instead. 12 | Use cmd shell instad of powershell when possible. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | launch.json 3 | settings.json 4 | vnv.bat 5 | .venv/ 6 | typings/ 7 | build/ 8 | dist/ 9 | 10 | # Secrets 11 | config.py 12 | thingsboard_api_tools/config.py 13 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "ty: Type Check", 6 | "type": "shell", 7 | "command": "ty check", 8 | "isBackground": false, 9 | "problemMatcher": [], 10 | "group": "build" 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2019 Christopher Eykamp 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a complete rewrite of the code I released here a few years ago; it covers much the same ground, but is now based on Pydantic data models. 2 | 3 | For now: update with: pip install git+git://github.com/eykamp/thingsboard_api_tools.git --upgrade 4 | 5 | # Motivation 6 | Thingsboard offers their own Python API, which seems to be autogenerated by Swagger. I have found it difficult to work with, so I reimplemented a small subset of the API that I found useful for my projects, mostly centered around Customers, Devices, and Dashboards. This API does things that are not straightforward to do through the API, such as retrieve the public URL for a dashboard, but there is a lot that it does not do. 7 | 8 | # Future 9 | My focus on this project is my own uses, and I will attempt to maintain this into the future, but no promises. Please feel free to fork or submit pull requests. I make no promises about maintenance beyond what I need for myself, and I would welcome other maintainers to join me. I am open to any suggestions or feedback. 10 | 11 | # Testing strategy 12 | Most of the core code has test coverage. The tests are designed to run on any Thingsboard instance, but generally assume the presence of some customers, devices, and/or dashboards. I use demo.thingsboard.io for my tests, but this does not let me test all things because the accounts you can create there are limited to user accounts. The API has a few functions related to tenants, for example, which cannot be tested with a test account at demo.thingsboard.io. Those tests are designed to fail gracefully. All test objects are (or should be) named with a `__TEST__` prefix to make it easier to identify them if things go wrong. 13 | 14 | # How to help 15 | I welcome PRs or assistance maintaining and expanding the project. I ask that any new functionality adhere to the code style (especially the use of type hints), and include test coverage. All tests should clean up after themselves. Luckily, on a project like this, tests are relatively easy. 16 | 17 | # A few quirks 18 | * We use `name` throughout, whereas Thingsboard generally (but not always) uses `title`. They are the same, but I like `name`. 19 | 20 | * Dashboards in Thingsboard are stored as a giant monolith of json. The API offers no tools for manipulating this -- it the server just acts as a datastore for the front-end code. This API models some of the internals of dashboards, which makes it possible to copy them and change the devices associated with different widgets. This code is inherently fragile, and is limited to my own reverse-engineering of selected dashboard objects. 21 | 22 | Normally, when you ask the API for a dashboard, you get back all the metadata (name, user, etc.) but not the JSON monolith. I call that a DashboardHeader. You can then request that monolith, and I call the Dashboard with it's widget configuration a Dashboard. It's a bit wierd, but it reflects how Thingsboard works. 23 | 24 | Note also that, as of this writing, the type hinting style straddles two eras. That will likely get cleaned up over time. 25 | 26 | # Root object 27 | The root object is called TbApi. You instantiate that with your credentials, and you can use that to create or retrieve other objects. 28 | 29 | # Examples 30 | There are tons of simple examples of how to to use this library in the test code. Here is one quick taste: 31 | 32 | ``` 33 | # Create yourself a demo account: https://demo.thingsboard.io/signup 34 | mothership_url = "http://demo.thingsboard.io:8080" 35 | thingsboard_username = "your_username" 36 | thingsboard_password = "your_password" 37 | 38 | tbapi = TbApi(mothership_url, thingsboard_username, thingsboard_password) # root object 39 | 40 | device = tbapi.get_device_by_name("ESP8266 Demo Device") # In the default demo dataset 41 | telemetry = device.get_telemetry() 42 | print(telemetry) 43 | ``` 44 | 45 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eykamp/thingsboard_api_tools/373076800f7df1cc0cc2ca8aa87efec45b05a009/pyproject.toml -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | python_files = test_*.py 4 | python_functions = test_* 5 | addopts = -v --tb=short -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | # python setup.py sdist # Creates a new archive in dist 4 | setup( 5 | name="thingsboard_api_tools", 6 | version="0.2", 7 | description="Tools for interacting with the Thingsboard API", 8 | url="https://github.com/eykamp/thingsboard_api_tools", 9 | author="Chris Eykamp", 10 | author_email="chris@eykamp.com", 11 | license="MIT", 12 | packages=find_packages(), # Automatically find all packages and sub-packages 13 | zip_safe=False, 14 | python_requires='>=3.10', 15 | install_requires=[ 16 | "requests", # Add other dependencies here 17 | ], 18 | classifiers=[ 19 | "Programming Language :: Python :: 3", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the tests directory a Python package 2 | -------------------------------------------------------------------------------- /tests/test_asset.py: -------------------------------------------------------------------------------- 1 | from thingsboard_api_tools.TbApi import TbApi 2 | from .config import mothership_url, thingsboard_username, thingsboard_password 3 | 4 | assert mothership_url 5 | assert thingsboard_username 6 | assert thingsboard_password 7 | 8 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 9 | 10 | 11 | def test_get_asset_types(): 12 | tbapi.get_asset_types() # Just make sure it doesn't blow up 13 | -------------------------------------------------------------------------------- /tests/test_attributes.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from faker import Faker 3 | from requests import HTTPError 4 | from datetime import datetime, timezone 5 | 6 | 7 | from thingsboard_api_tools.TbApi import TbApi 8 | 9 | from .config import mothership_url, thingsboard_username, thingsboard_password 10 | 11 | assert mothership_url 12 | assert thingsboard_username 13 | assert thingsboard_password 14 | 15 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 16 | 17 | fake = Faker() 18 | 19 | 20 | def test_server_attributes(): 21 | """ This will work the same for any model inheriting from HasAttributes; no need to test them all. """ 22 | 23 | attr_names = ["testattr", "testattr2", "new_test_attr"] 24 | attr_dict: dict[str, Any] = {attr_names[0]: fake.pyint(), attr_names[1]: fake.pystr()} 25 | 26 | cust = tbapi.create_customer(name=fake_cust_name(), server_attributes=attr_dict) 27 | 28 | try: 29 | attrs = cust.get_server_attributes() 30 | assert attr_names[0] in attrs 31 | 32 | atr1_last_updated = attrs[attr_names[0]].last_updated 33 | assert attrs[attr_names[0]].key == attr_names[0] 34 | assert attrs[attr_names[0]].value == attr_dict[attr_names[0]] 35 | assert attrs[attr_names[1]].key == attr_names[1] 36 | assert attrs[attr_names[1]].value == attr_dict[attr_names[1]] 37 | 38 | # Update by changing attr struct: 39 | new_attr_val = fake.pybool() # Also changing type 40 | attrs[attr_names[0]].value = new_attr_val 41 | cust.set_server_attributes(attrs) 42 | 43 | attrs2 = cust.get_server_attributes() 44 | # Server clock is off; this might help 45 | assert (datetime.now(timezone.utc) - attrs2[attr_names[0]].last_updated).seconds < 10 or (attrs2[attr_names[0]].last_updated - datetime.now(timezone.utc)).seconds < 10 46 | assert attrs2[attr_names[0]].last_updated > atr1_last_updated # Time got updated 47 | 48 | # Update by sending dict 49 | new_attr_val2 = fake.pyint() # Changing type 50 | cust.set_server_attributes({attr_names[1]: new_attr_val2}) 51 | 52 | attrs3 = cust.get_server_attributes() 53 | assert (datetime.now(timezone.utc) - attrs3[attr_names[1]].last_updated).seconds < 10 or (attrs3[attr_names[1]].last_updated - datetime.now(timezone.utc)).seconds < 10 54 | assert attrs3[attr_names[1]].last_updated > atr1_last_updated # Time got updated 55 | 56 | # Delete an attribute 57 | assert cust.delete_server_attributes(attr_names[0]) 58 | attrs4 = cust.get_server_attributes() 59 | assert attr_names[0] not in attrs4 and attr_names[1] in attrs4 60 | 61 | # Delete missing attribute; always returns true if operation generally worked, even if attribute 62 | # isn't actually present 63 | assert cust.delete_server_attributes("Kalamazoo!") 64 | 65 | # Add new attribute 66 | new_val = fake.pystr() 67 | cust.set_server_attributes({attr_names[2]: new_val}) 68 | attrs4 = cust.get_server_attributes() 69 | assert attr_names[2] in attrs4 and attr_names[0] not in attrs4 and attr_names[1] in attrs4 # We have the attrs we expect 70 | assert attrs4[attr_names[2]].last_updated > attrs4[attr_names[1]].last_updated # Update times are sane 71 | assert attrs4[attr_names[2]].value == new_val 72 | 73 | # Ensure no crossover between scopes: 74 | assert cust.get_client_attributes() == {} # These make no sense in the customer context, but still work 75 | assert cust.get_shared_attributes() == {} # These make no sense in the customer context, but still work 76 | 77 | finally: 78 | assert cust.delete() 79 | 80 | # Gettting attributes of deleted client fails as expected 81 | try: 82 | cust.get_server_attributes() 83 | except HTTPError as ex: 84 | assert ex.response.status_code == 404 85 | else: 86 | assert False 87 | 88 | 89 | def fake_cust_name(): 90 | return "__TEST_CUST__ " + fake.name() 91 | -------------------------------------------------------------------------------- /tests/test_customer.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from faker import Faker 3 | import uuid 4 | import requests 5 | from datetime import datetime, timezone 6 | 7 | 8 | from thingsboard_api_tools.Customer import Customer, CustomerId 9 | from thingsboard_api_tools.TbApi import TbApi 10 | from thingsboard_api_tools.TbModel import Id 11 | 12 | from .config import mothership_url, thingsboard_username, thingsboard_password 13 | 14 | assert mothership_url 15 | assert thingsboard_username 16 | assert thingsboard_password 17 | 18 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 19 | 20 | fake = Faker() 21 | 22 | 23 | def test_get_all_customers(): 24 | tbapi.get_all_customers() 25 | 26 | 27 | def test_customer_id(): 28 | """ There's something goofy in the code here; make sure that customer_id passes smoke test. """ 29 | cust = tbapi.get_all_customers()[0] 30 | cust_id = cust.customer_id 31 | assert isinstance(cust_id, CustomerId) 32 | assert cust_id.id == cust.id 33 | assert cust_id.id.entity_type == "CUSTOMER" 34 | 35 | 36 | def test_create_edit_and_delete_customer(): 37 | orig_custs = tbapi.get_all_customers() 38 | name = fake_cust_name() 39 | attr_val = fake.name() 40 | key1 = fake.name() 41 | key2 = fake.name() 42 | 43 | data: dict[str, Any] = { 44 | "name": name, 45 | "address": fake.address().split("\n")[0], 46 | "address2": "", 47 | "city": fake.city(), 48 | "state": fake.state(), 49 | "zip": fake.postcode(), 50 | "country": "", 51 | "email": fake.email(), 52 | "phone": fake.phone_number(), 53 | "additional_info": {"key1": key1, "key2": key2}, 54 | } 55 | 56 | # Create a customer using this factory method 57 | cust = tbapi.create_customer(**data, server_attributes={"test_attr": attr_val}) # type: ignore 58 | 59 | assert cust.id.entity_type == "CUSTOMER" 60 | 61 | dumped = cust.model_dump() 62 | for k, v in data.items(): 63 | assert dumped[k] == v 64 | assert cust.additional_info and cust.additional_info["key1"] == key1 and cust.additional_info["key2"] == key2 65 | 66 | # Verify the attribute we passed got set 67 | attr = cust.get_server_attributes()["test_attr"] 68 | assert attr.key == "test_attr" 69 | assert attr.value == attr_val 70 | 71 | all_custs = tbapi.get_all_customers() 72 | 73 | assert len(all_custs) == len(orig_custs) + 1 74 | assert find_id(all_custs, cust.id) 75 | 76 | # Edit 77 | cust.name = fake_cust_name() 78 | cust.city = fake.city() 79 | cust.update() 80 | 81 | cust2 = tbapi.get_customer_by_id(cust.id) 82 | assert cust2 83 | assert cust2.name == cust.name 84 | assert cust2.city == cust.city 85 | 86 | # Delete 87 | assert cust.delete() 88 | 89 | all_custs = tbapi.get_all_customers() 90 | assert len(all_custs) == len(orig_custs) 91 | assert not find_id(all_custs, cust.id) 92 | 93 | 94 | def test_get_cust_by_bad_name(): 95 | assert tbapi.get_customer_by_name(fake.name()) is None # Bad name brings back no customers 96 | 97 | 98 | def test_update_bogus_customer(): 99 | """ Not a likely scenario, mostly curious what happens. """ 100 | cust = tbapi.get_all_customers()[0] 101 | cust.id.id = str(uuid.uuid4()) 102 | 103 | try: 104 | cust.update() 105 | except requests.HTTPError: 106 | pass 107 | else: 108 | assert False 109 | 110 | 111 | def test_created_time(): 112 | # Let's make sure that using a datetime is ok for created_time. This test is causing problems 113 | # because server time differs from our time. Hence the ugliness. 114 | c = tbapi.create_customer(fake_cust_name()) 115 | msg = f"{datetime.now(timezone.utc)}, {c.created_time}" 116 | assert c.created_time 117 | assert (datetime.now(timezone.utc) - c.created_time).seconds < 10 or (c.created_time - datetime.now(timezone.utc)).seconds < 10, msg 118 | assert c.delete() # Cleanup 119 | 120 | 121 | def test_get_customer_by_name(): 122 | cust = tbapi.get_all_customers()[-1] 123 | assert cust.name 124 | 125 | cust2 = tbapi.get_customer_by_name(cust.name) 126 | assert cust2 and cust2.id == cust.id 127 | 128 | 129 | def find_id(custs: list[Customer], id: Id): 130 | for c in custs: 131 | if c.id.id == id.id: 132 | return True 133 | 134 | return False 135 | 136 | 137 | def test_is_public(): 138 | device = tbapi.create_device(name=fake_cust_name(), type=None, label=None) 139 | assert device.get_customer() is None 140 | 141 | device.make_public() 142 | assert device.is_public() 143 | 144 | public_customer = device.get_customer() 145 | assert public_customer 146 | assert public_customer.name == "Public" # This really is the public customer 147 | assert public_customer.is_public() # <<< This is what we want to test 148 | 149 | not_public_customer = tbapi.get_all_customers()[0] 150 | assert not_public_customer.name != "Public" # This isn't the public customer 151 | assert not not_public_customer.is_public() # <<< This is what we want to test 152 | 153 | assert device.delete() # Cleanup 154 | 155 | 156 | def test_saving(): 157 | old_phone = fake.phone_number() 158 | cust = tbapi.create_customer(fake_cust_name(), phone=old_phone) 159 | cust2 = tbapi.get_customer_by_id(cust.id) 160 | assert cust2 161 | assert old_phone == cust2.phone 162 | 163 | new_phone = fake.phone_number() 164 | cust.phone = new_phone # Change the attribute 165 | 166 | # Verify the attribute hasn't changed on the server 167 | cust3 = tbapi.get_customer_by_id(cust.id) 168 | assert cust3 169 | assert cust3.phone == old_phone 170 | cust.update() 171 | cust3 = tbapi.get_customer_by_id(cust.id) 172 | assert cust3 173 | assert cust3.phone == new_phone 174 | 175 | assert cust.delete() 176 | 177 | 178 | def test_get_devices(): 179 | cust = tbapi.create_customer(name=fake_cust_name()) 180 | 181 | assert cust.delete() 182 | 183 | 184 | def fake_cust_name(): 185 | return "__TEST_CUST__ " + fake.name() 186 | -------------------------------------------------------------------------------- /tests/test_dashboard.py: -------------------------------------------------------------------------------- 1 | from faker import Faker 2 | 3 | from thingsboard_api_tools.TbApi import TbApi 4 | from .config import mothership_url, thingsboard_username, thingsboard_password 5 | 6 | assert mothership_url 7 | assert thingsboard_username 8 | assert thingsboard_password 9 | 10 | 11 | fake = Faker() 12 | 13 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 14 | 15 | 16 | def test_get_all_dashboards(): 17 | tbapi.get_all_dashboard_headers() 18 | 19 | 20 | def test_get_dashboard_by_id(): 21 | dashboard = tbapi.get_all_dashboard_headers()[0] 22 | dash = tbapi.get_dashboard_header_by_id(dashboard.id.id) 23 | 24 | assert dash == dashboard 25 | 26 | 27 | def test_get_dashboard_by_name(): 28 | dashboard_header = tbapi.get_all_dashboard_headers()[0] 29 | assert dashboard_header 30 | 31 | name = dashboard_header.name 32 | assert name 33 | dash_header = tbapi.get_dashboard_by_name(name) 34 | assert dash_header == dashboard_header.get_dashboard() 35 | 36 | 37 | def test_copy_dashboard_without_id(): 38 | # We'll probably never build a dasboard from scratch (they're very complicated and very 39 | # undocumented), so let's grab a dashboard configuration (the complex bit) from somewhere to start with 40 | dashboard = tbapi.get_all_dashboard_headers()[0] 41 | template = dashboard.get_dashboard() 42 | 43 | name = fake_dash_name() 44 | dash = tbapi.create_dashboard(name, template) 45 | 46 | j1 = dash.model_dump() 47 | j2 = template.model_dump() 48 | 49 | # New dashboard we just created should be the same as the source dashboard, except for a few fields 50 | del j1["id"], j2["id"], j1["name"], j2["name"] 51 | 52 | assert j1 == j2 53 | 54 | assert tbapi.get_dashboard_by_name(name) 55 | dash.delete() 56 | assert not tbapi.get_dashboard_by_name(name) 57 | 58 | 59 | def test_copy_dashboard_with_id(): 60 | """ 61 | If we specify an Id for create_dashboard to use, it will overwrite an existing dashboard. It is 62 | essentially an update operation. Test that this works. 63 | """ 64 | dashboard = target = None 65 | try: 66 | for dash in tbapi.get_all_dashboard_headers(): 67 | dashboard = dash.get_dashboard() 68 | if dashboard.configuration: 69 | break 70 | assert dashboard 71 | 72 | if not dashboard.configuration: # If this happens we're going to need to create a dummy configuation to use for testing 73 | assert False, "Cannot find any dashboards that have a configuration -- easiest fix is to configure a widget on an existing dashboard on the test system" 74 | 75 | template_dash = dashboard.get_dashboard() 76 | 77 | # Create a target dashboard 78 | target = tbapi.create_dashboard(fake_dash_name()) 79 | 80 | name = fake_dash_name() 81 | dash = tbapi.create_dashboard(name, template_dash, target.id) 82 | 83 | assert dash.id == target.id 84 | assert dash.name == name 85 | assert dash.configuration == template_dash.configuration 86 | 87 | # Double check 88 | dash = tbapi.get_dashboard_by_id(target.id) 89 | assert dash.name == name 90 | assert dash.configuration == template_dash.configuration 91 | 92 | except Exception: 93 | raise 94 | 95 | finally: 96 | if target: 97 | assert target.delete() 98 | 99 | 100 | def test_saving(): 101 | dash = tbapi.create_dashboard(fake_dash_name()) 102 | 103 | try: 104 | old_order = dash.mobile_order 105 | new_order = (old_order or 0) + 1 # Protect against None 106 | dash.mobile_order = new_order # Change the attribute 107 | 108 | # Verify the attribute hasn't changed on the server 109 | dash2 = tbapi.get_dashboard_header_by_id(dash.id) 110 | assert dash2.mobile_order == old_order 111 | dash.update() 112 | dash2 = tbapi.get_dashboard_header_by_id(dash.id) 113 | assert dash2.mobile_order == new_order 114 | 115 | finally: 116 | assert dash.delete() 117 | 118 | 119 | def test_is_public(): 120 | """ Also tests make_public() and make_private() """ 121 | dash = tbapi.create_dashboard(fake_dash_name()) 122 | 123 | assert not dash.is_public() # Not public by default 124 | dash.make_public() 125 | assert dash.is_public() 126 | dash.make_private() 127 | assert not dash.is_public() 128 | assert dash.delete() 129 | 130 | 131 | def test_assign_to(): 132 | """ Also tests get_customers() """ 133 | dash = tbapi.create_dashboard(fake_dash_name()) 134 | assert dash.get_customers() == [] # No customers by default 135 | 136 | custs = tbapi.get_all_customers() 137 | assert len(custs) > 0 # Make sure we have one 138 | dash.assign_to(custs[0]) # Assign 139 | 140 | assert dash.get_customers() == [custs[0]] # We've assigned our customer! 141 | 142 | assert dash.delete() # Cleanup 143 | 144 | 145 | def test_get_public_url(): 146 | """ Also tests make_public() and make_private() """ 147 | dash = tbapi.create_dashboard(fake_dash_name()) 148 | assert dash.get_public_url() is None 149 | 150 | dash.make_public() 151 | puburl = dash.get_public_url() 152 | assert puburl and puburl.startswith("http") 153 | 154 | dash.make_private() 155 | assert dash.get_public_url() is None 156 | assert dash.delete() 157 | 158 | 159 | def test_get_config_for_dashboard(): 160 | dash = tbapi.get_all_dashboard_headers()[0] 161 | dash_def = dash.get_dashboard() # Get the full dashboard, including its configuration 162 | 163 | # A dash_def is just a Dashboard with a configuration object 164 | dict1 = dash.model_dump() 165 | dict2 = dash_def.model_dump() 166 | del dict2["configuration"] 167 | assert dict1 == dict2 168 | 169 | 170 | def test_dashboard_with_no_widgets(): 171 | dash = tbapi.create_dashboard(fake_dash_name()) 172 | assert dash.configuration is None # Dashboards with no widgets have no configuration 173 | 174 | dash_def = dash.get_dashboard() 175 | assert dash_def.configuration is None 176 | 177 | assert dash.delete() 178 | 179 | 180 | def fake_dash_name() -> str: 181 | return "__TEST_DASH__ " + fake.name() 182 | -------------------------------------------------------------------------------- /tests/test_device.py: -------------------------------------------------------------------------------- 1 | from faker import Faker 2 | 3 | from thingsboard_api_tools.TbApi import TbApi 4 | from thingsboard_api_tools.Device import Device 5 | 6 | from .config import mothership_url, thingsboard_username, thingsboard_password 7 | 8 | assert mothership_url 9 | assert thingsboard_username 10 | assert thingsboard_password 11 | 12 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 13 | 14 | fake = Faker() 15 | 16 | 17 | def test_get_all_devices(): 18 | tbapi.get_all_devices() 19 | 20 | 21 | def test_create_device(): 22 | """ 23 | Tests creating a device without specifying a customer 24 | Also tests get_device_by_name(name), get_server_attributes(), get_shared_attributes(), device.delete() 25 | """ 26 | name = fake_device_name() + " " + fake.name() 27 | server_attributes = fake.pydict(4, allowed_types=(str, int)) # type: ignore (pydict) 28 | shared_attributes = fake.pydict(4, allowed_types=(str, int)) # type: ignore (pydict) 29 | 30 | device = tbapi.create_device( 31 | name=name, 32 | type=fake.company(), 33 | label=fake.bban(), 34 | additional_info=fake.pydict(3, allowed_types=(str, int, float)), # type: ignore (pydict) 35 | customer=None, 36 | server_attributes=server_attributes, 37 | shared_attributes=shared_attributes, 38 | ) 39 | 40 | # Verify we can find the device 41 | dev = tbapi.get_device_by_name(name) 42 | assert dev 43 | 44 | assert device.model_dump() == dev.model_dump() 45 | assert dev.customer_id.id == TbApi.NULL_GUID # No customer specified gets null id 46 | 47 | assert dev.tenant_id == tbapi.get_current_user().tenant_id # Tenant get assigned to login id's tenant 48 | 49 | # Make sure our attributes got set 50 | attrs = dev.get_server_attributes() 51 | assert attrs["active"] # Should be added by the server 52 | for k, v in server_attributes.items(): 53 | assert attrs[k].value == v 54 | 55 | attrs = dev.get_shared_attributes() 56 | for k, v in shared_attributes.items(): 57 | assert attrs[k].value == v 58 | 59 | # Cleanup 60 | assert device.delete() 61 | assert tbapi.get_device_by_name(name) is None 62 | 63 | 64 | def test_create_device_with_customer(): 65 | """ 66 | If you specify a customer when creating a device, make sure that customer gets assigned 67 | 68 | Tests creating a device without specifying a customer 69 | Also tests get_device_by_name(name) 70 | """ 71 | name = fake_device_name() + " " + fake.name() 72 | server_attributes = fake.pydict(4, allowed_types=(str, int)) # type: ignore (pydict) 73 | shared_attributes = fake.pydict(4, allowed_types=(str, int)) # type: ignore (pydict) 74 | 75 | customer = tbapi.get_all_customers()[0] 76 | 77 | device = tbapi.create_device( 78 | name=name, 79 | type=fake.company(), 80 | label=fake.bban(), 81 | additional_info=fake.pydict(3, allowed_types=(str, int, float)), # type: ignore (pydict) 82 | customer=customer, 83 | server_attributes=server_attributes, 84 | shared_attributes=shared_attributes, 85 | ) 86 | 87 | dev = tbapi.get_device_by_name(name) 88 | assert dev 89 | assert dev.customer_id == customer.id 90 | 91 | assert device.delete() 92 | 93 | 94 | def test_make_public_and_is_public(): 95 | device: Device = tbapi.create_device(name=fake_device_name()) 96 | assert device.get_customer() is None 97 | assert not device.is_public() 98 | 99 | device.make_public() 100 | assert device.is_public() 101 | 102 | assert device.delete() # Cleanup 103 | 104 | 105 | def test_assign_to(): 106 | device: Device = tbapi.create_device(name=fake_device_name()) 107 | assert device.get_customer() is None 108 | 109 | customer = tbapi.get_all_customers()[0] 110 | assert customer 111 | 112 | device.assign_to(customer) 113 | assert device.get_customer() == customer 114 | 115 | dev = tbapi.get_device_by_name(device.name) 116 | assert dev 117 | assert dev == device 118 | assert dev.get_customer() == customer 119 | 120 | assert device.delete() # Cleanup 121 | 122 | 123 | def test_double_delete(): 124 | device: Device = tbapi.create_device(name=fake_device_name()) 125 | assert device.delete() # Device exists: Return True 126 | assert not device.delete() # Device doesn't exist: No error, return False 127 | 128 | 129 | def test_saving(): 130 | old_label = fake.color_name() 131 | device = tbapi.create_device(fake_device_name(), label=old_label) 132 | device2 = tbapi.get_device_by_id(device.id) 133 | assert device2 134 | assert old_label == device2.label 135 | 136 | new_label = fake.color_name() 137 | device.label = new_label # Change the attribute 138 | 139 | # Verify the attribute hasn't changed on the server 140 | device3 = tbapi.get_device_by_id(device.id) 141 | assert device3 142 | assert device3.label == old_label 143 | device.update() 144 | 145 | device3 = tbapi.get_device_by_id(device.id) 146 | assert device3 147 | assert device3.label == new_label 148 | 149 | assert device.delete() 150 | 151 | 152 | def fake_device_name(): 153 | return "__TEST_DEV__ " + fake.name() 154 | -------------------------------------------------------------------------------- /tests/test_device_profile.py: -------------------------------------------------------------------------------- 1 | from thingsboard_api_tools.TbApi import TbApi 2 | from .config import mothership_url, thingsboard_username, thingsboard_password 3 | 4 | assert mothership_url 5 | assert thingsboard_username 6 | assert thingsboard_password 7 | 8 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 9 | 10 | 11 | def test_get_all_devices_profiles(): 12 | tbapi.get_all_device_profiles() 13 | 14 | 15 | def test_get_profile_from_device(): 16 | dev = tbapi.get_all_devices()[0] 17 | assert dev 18 | assert dev.get_profile() 19 | assert dev.get_profile_info() 20 | 21 | 22 | def test_get_device_profile_by_id(): 23 | profile = tbapi.get_all_device_profiles()[0] 24 | prof = tbapi.get_device_profile_by_id(profile.id.id) 25 | 26 | assert prof == profile 27 | 28 | 29 | def test_get_device_profile_by_name(): 30 | profile = tbapi.get_all_device_profiles()[0] 31 | prof = tbapi.get_device_profile_by_name(profile.name) 32 | 33 | assert prof == profile 34 | -------------------------------------------------------------------------------- /tests/test_device_profile_info.py: -------------------------------------------------------------------------------- 1 | from thingsboard_api_tools.TbApi import TbApi 2 | from .config import mothership_url, thingsboard_username, thingsboard_password 3 | 4 | assert mothership_url 5 | assert thingsboard_username 6 | assert thingsboard_password 7 | 8 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 9 | 10 | 11 | def test_get_all_device_profile_infos(): 12 | tbapi.get_all_device_profile_infos() 13 | 14 | 15 | def test_get_device_profile_info_by_id(): 16 | profile_info = tbapi.get_all_device_profile_infos()[0] 17 | prof = tbapi.get_device_profile_info_by_id(profile_info.id.id) 18 | 19 | assert prof == profile_info 20 | 21 | 22 | def test_get_device_profile_info_by_name(): 23 | profile_info = tbapi.get_all_device_profile_infos()[0] 24 | prof = tbapi.get_device_profile_info_by_name(profile_info.name) 25 | 26 | assert prof == profile_info 27 | -------------------------------------------------------------------------------- /tests/test_telemetry.py: -------------------------------------------------------------------------------- 1 | from faker import Faker 2 | from datetime import datetime 3 | import time 4 | import json as Json 5 | 6 | from thingsboard_api_tools.TbApi import TbApi 7 | from thingsboard_api_tools.Device import prepare_ts 8 | from thingsboard_api_tools.TelemetryRecord import TelemetryRecord 9 | 10 | from .config import mothership_url, thingsboard_username, thingsboard_password 11 | 12 | assert mothership_url 13 | assert thingsboard_username 14 | assert thingsboard_password 15 | 16 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 17 | 18 | fake = Faker() 19 | 20 | 21 | def test_new_device_has_no_telemetry(): 22 | """ Make sure things work as expected when there is no telemetry to be had. """ 23 | dev = tbapi.create_device(fake_device_name()) 24 | try: 25 | tel = dev.get_telemetry("xxx") 26 | 27 | assert len(tel) == 0 28 | telkeys = dev.get_telemetry_keys() 29 | assert len(telkeys) == 0 30 | 31 | finally: 32 | dev.delete() 33 | 34 | 35 | def test_telemetry(): 36 | """ Tests send_telemetry(), get_latest_telemetry(), and get_telemetry(). """ 37 | dev = tbapi.create_device(fake_device_name()) 38 | keys = [fake.last_name(), fake.last_name(), fake.last_name()] 39 | data = [(fake.pystr(), fake.pystr(), fake.pystr()) for _ in range(2)] # Only test with str to avoid precision and .0 issues 40 | 41 | try: 42 | # Send a single record 43 | dev.send_telemetry({keys[i]: data[0][i] for i in range(len(keys))}) 44 | ts0 = 0 # Kill warning 45 | 46 | tries = 20 47 | while tries > 0: 48 | try: 49 | latest = dev.get_latest_telemetry(keys[0]) 50 | ts0 = latest[keys[0]][0]["ts"] # ts0 is a time assigned by the server, which may be out of sync on machine running tests 51 | 52 | assert latest == {keys[0]: [{"ts": ts0, "value": data[0][0]}]}, f"Try #{tries}" # Get latest with single key 53 | latest = dev.get_latest_telemetry([keys[1], keys[2]]) 54 | assert latest == {keys[i]: [{"ts": ts0, "value": data[0][i]}] for i in (1, 2)}, f"Try #{tries}" # Get latest with multiple keys 55 | except AssertionError: 56 | tries -= 1 57 | if tries == 0: 58 | raise 59 | time.sleep(.25) 60 | else: 61 | break 62 | 63 | 64 | # Send data with a timestamp, make sure it's later than ts0 so these values will be "latest"; sometimes server clock is out of sync 65 | ts1 = ts0 + 2500 66 | dev.send_telemetry({keys[i]: data[1][i] for i in range(len(keys))}, ts=ts1) 67 | 68 | 69 | tries = 20 70 | while tries > 0: 71 | try: 72 | latest = dev.get_latest_telemetry(keys) 73 | assert latest == {keys[i]: [{"ts": ts1, "value": data[1][i]}] for i in range(len(keys))}, f'{{keys[i]: [{"ts": ts1, "value": data[1][i]}] for i in range(len(keys))}} should equal {latest} >> Try #{tries}' 74 | assert set(dev.get_telemetry_keys()) == set(keys), f"Try #{tries}" # Use set to make order not matter... because it doesn't 75 | 76 | except AssertionError: 77 | tries -= 1 78 | if tries == 0: 79 | raise 80 | time.sleep(.25) 81 | else: 82 | break 83 | # We aren't trying to test end_ts here, but due to time differences on client and server, we 84 | # need to do this to be sure we get back the data we expect. Unlikely to be important in 85 | # production. 86 | tel = dev.get_telemetry(keys, end_ts=ts1 + 1000) 87 | for i, k in enumerate(keys): 88 | # Again, use sets to ensure we don't get tripped up by the order in which the data comes back 89 | set1 = set([Json.dumps(tel[k][x]) for x in (0, 1)]) 90 | set2 = set([Json.dumps({"ts": [ts0, ts1][x], "value": data[x][i]}) for x in (0, 1)]) 91 | assert set1 == set2 92 | 93 | # Specify a time range that will only bring back first item we sent 94 | tel = dev.get_telemetry(keys, start_ts=1000000, end_ts=ts0 + 500) 95 | for i, k in enumerate(keys): 96 | assert len(tel[k]) == 1 97 | assert tel[k][0] == {"ts": ts0, "value": data[0][i]} 98 | 99 | finally: 100 | dev.delete() 101 | 102 | 103 | def test_prepare_timestamp(): 104 | t = datetime.now() 105 | e = prepare_ts(t) # Converts datetimes... 106 | 107 | assert e == int(t.timestamp() * 1000) # Kind of lame... this is just what the fn does 108 | assert e == prepare_ts(e) # ...but lets ints through unmolested 109 | 110 | 111 | def test_telemetry_record_serializer(): 112 | """ There was something weird about this serializer... test fixed up version. """ 113 | import re 114 | 115 | ts = datetime.now() 116 | telrec = TelemetryRecord(values={"a": 1, "b": "two"}, ts=ts) 117 | 118 | assert telrec.ts == ts 119 | 120 | # Test that str(telrec) contains "ts: " format 121 | telrec_str = str(telrec) 122 | assert re.search(r"ts: \d{13}", telrec_str), f"Expected 'ts: ' pattern in: {telrec_str}" 123 | 124 | 125 | def fake_device_name(): 126 | return "__TEST_DEV__ " + fake.name() 127 | -------------------------------------------------------------------------------- /tests/test_tenant.py: -------------------------------------------------------------------------------- 1 | from requests import HTTPError 2 | 3 | from thingsboard_api_tools.TbApi import TbApi 4 | from .config import mothership_url, thingsboard_username, thingsboard_password 5 | 6 | assert mothership_url 7 | assert thingsboard_username 8 | assert thingsboard_password 9 | 10 | 11 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 12 | 13 | def test_get_all_tenants(): 14 | try: 15 | tbapi.get_all_tenants() 16 | except HTTPError as ex: 17 | assert ex.response.status_code == 403 # If we don't have permissions, we can't test this 18 | 19 | 20 | def test_get_tenant_assets(): 21 | tbapi.get_tenant_assets() 22 | 23 | 24 | def test_get_tenant_devices(): 25 | tbapi.get_tenant_devices() 26 | 27 | 28 | # Don't have the methods for this yet 29 | # def test_saving(): 30 | # old_email = fake.email() 31 | # tenant = tbapi.create_tenant(fake_cust_name(), email=old_email) 32 | # tenant2 = tbapi.get_tenant_by_id(tenant.id) 33 | # assert tenant2 34 | # assert old_email == tenant2.email 35 | 36 | # new_email = fake.email_number() 37 | # tenant.email = new_email # Change the attribute 38 | 39 | # # Verify the attribute hasn't changed on the server 40 | # tenant3 = tbapi.get_tenant_by_id(tenant.id) 41 | # assert tenant3 42 | # assert tenant3.email == old_email 43 | # tenant.update() 44 | # tenant3 = tbapi.get_tenant_by_id(tenant.id) 45 | # assert tenant3 46 | # assert tenant3.email == new_email 47 | 48 | # assert tenant.delete() 49 | -------------------------------------------------------------------------------- /tests/test_user.py: -------------------------------------------------------------------------------- 1 | from thingsboard_api_tools.TbApi import TbApi 2 | from thingsboard_api_tools.TbModel import Id 3 | from thingsboard_api_tools.User import User 4 | 5 | from .config import mothership_url, thingsboard_username, thingsboard_password 6 | 7 | assert mothership_url 8 | assert thingsboard_username 9 | assert thingsboard_password 10 | 11 | tbapi = TbApi(url=mothership_url, username=thingsboard_username, password=thingsboard_password) 12 | 13 | 14 | def test_get_current_user(): 15 | assert isinstance(tbapi.get_current_user(), User) 16 | 17 | 18 | def test_get_tenant_id(): 19 | assert isinstance(tbapi.get_current_tenant_id(), Id) 20 | -------------------------------------------------------------------------------- /thingsboard_api_tools.egg-info/PKG-INFO: -------------------------------------------------------------------------------- 1 | Metadata-Version: 2.4 2 | Name: thingsboard_api_tools 3 | Version: 0.2 4 | Summary: Tools for interacting with the Thingsboard API 5 | Home-page: https://github.com/eykamp/thingsboard_api_tools 6 | Author: Chris Eykamp 7 | Author-email: chris@eykamp.com 8 | License: MIT 9 | Classifier: Programming Language :: Python :: 3 10 | Classifier: License :: OSI Approved :: MIT License 11 | Classifier: Operating System :: OS Independent 12 | Requires-Python: >=3.10 13 | License-File: LICENSE 14 | Requires-Dist: requests 15 | Dynamic: author 16 | Dynamic: author-email 17 | Dynamic: classifier 18 | Dynamic: home-page 19 | Dynamic: license 20 | Dynamic: license-file 21 | Dynamic: requires-dist 22 | Dynamic: requires-python 23 | Dynamic: summary 24 | -------------------------------------------------------------------------------- /thingsboard_api_tools.egg-info/SOURCES.txt: -------------------------------------------------------------------------------- 1 | LICENSE 2 | README.md 3 | setup.py 4 | tests/test_asset.py 5 | tests/test_attributes.py 6 | tests/test_customer.py 7 | tests/test_dashboard.py 8 | tests/test_device.py 9 | tests/test_device_profile.py 10 | tests/test_device_profile_info.py 11 | tests/test_telemetry.py 12 | tests/test_tenant.py 13 | tests/test_user.py 14 | thingsboard_api_tools/Customer.py 15 | thingsboard_api_tools/Dashboard.py 16 | thingsboard_api_tools/Device.py 17 | thingsboard_api_tools/DeviceProfile.py 18 | thingsboard_api_tools/EntityType.py 19 | thingsboard_api_tools/HasAttributes.py 20 | thingsboard_api_tools/TbApi.py 21 | thingsboard_api_tools/TbModel.py 22 | thingsboard_api_tools/TelemetryRecord.py 23 | thingsboard_api_tools/Tenant.py 24 | thingsboard_api_tools/User.py 25 | thingsboard_api_tools/__init__.py 26 | thingsboard_api_tools/py.typed 27 | thingsboard_api_tools.egg-info/PKG-INFO 28 | thingsboard_api_tools.egg-info/SOURCES.txt 29 | thingsboard_api_tools.egg-info/dependency_links.txt 30 | thingsboard_api_tools.egg-info/not-zip-safe 31 | thingsboard_api_tools.egg-info/requires.txt 32 | thingsboard_api_tools.egg-info/top_level.txt -------------------------------------------------------------------------------- /thingsboard_api_tools.egg-info/dependency_links.txt: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /thingsboard_api_tools.egg-info/not-zip-safe: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /thingsboard_api_tools.egg-info/requires.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /thingsboard_api_tools.egg-info/top_level.txt: -------------------------------------------------------------------------------- 1 | thingsboard_api_tools 2 | -------------------------------------------------------------------------------- /thingsboard_api_tools/Customer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | from typing import Any, TYPE_CHECKING 19 | from pydantic import Field # pip install pydantic 20 | 21 | from .HasAttributes import HasAttributes 22 | from .TbModel import TbModel, Id, TbObject 23 | 24 | 25 | if TYPE_CHECKING: 26 | from .Device import Device 27 | 28 | 29 | class CustomerId(TbModel): 30 | """ This is an Id with couple of extra fields. """ 31 | 32 | id: Id = Field(alias="customerId") 33 | public: bool = False 34 | name: str = Field(alias="title") 35 | 36 | 37 | def __str__(self) -> str: 38 | return f"CustomerId ({'PUBLIC' if self.public else self.name})" 39 | 40 | 41 | def __eq__(self, other: Any) -> bool: 42 | return self.id == other # We'll put all the weird cases in Id's __eq__ and redirect ourselves there 43 | 44 | 45 | class Customer(TbObject, HasAttributes): 46 | name: str = Field(alias="title") # "title" is the oficial TB name field; "name" is read-only, maps to this 47 | tenant_id: Id = Field(alias="tenantId") 48 | address: str | None 49 | address2: str | None 50 | city: str | None 51 | state: str | None 52 | zip: str | None 53 | country: str | None 54 | email: str | None 55 | phone: str | None 56 | additional_info: dict[str, Any] | None = Field(default={}, alias="additionalInfo") 57 | 58 | 59 | def update(self): 60 | return self.tbapi.post("/api/customer", self.model_dump_json(by_alias=True), "Error updating customer") 61 | 62 | 63 | def is_public(self) -> bool: 64 | if not self.additional_info: 65 | return False 66 | 67 | return self.additional_info.get("isPublic", False) 68 | 69 | 70 | def one_line_address(self) -> str: 71 | """ Reutrn a 1-line formatted address. """ 72 | return f"{self.address}, {((self.address2 + ', ') if self.address2 else '')} {self.city}, {self.state}" 73 | 74 | 75 | def __eq__(self, other: object) -> bool: 76 | if not isinstance(other, Customer): 77 | return False 78 | 79 | return self.id == other.id 80 | 81 | 82 | @property 83 | def customer_id(self) -> CustomerId: 84 | return CustomerId(customerId=self.id, public=self.is_public(), title=self.name if self.name else "") # type: ignore 85 | 86 | 87 | def get_devices(self) -> list["Device"]: 88 | """ 89 | Returns a list of all devices associated with a customer; will not include public devices! 90 | """ 91 | from .Device import Device 92 | 93 | cust_id = self.id.id 94 | 95 | all_results = self.tbapi.get_paged(f"/api/customer/{cust_id}/devices", f"Error retrieving devices for customer '{cust_id}'") 96 | return self.tbapi.tb_objects_from_list(all_results, Device) # Circular... Device gets defined below, but refers to Customer... 97 | 98 | 99 | def delete(self) -> bool: 100 | """ 101 | Deletes the customer from the server, returns True if customer was deleted, False if it did not exist 102 | """ 103 | return self.tbapi.delete(f"/api/customer/{self.id.id}", f"Error deleting customer '{self.id.id}'") 104 | -------------------------------------------------------------------------------- /thingsboard_api_tools/Dashboard.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the`` 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | from typing import Dict, List, Any, Optional 19 | from pydantic import Field 20 | 21 | from .TbModel import Id, TbObject, TbModel 22 | from .Customer import Customer, CustomerId 23 | 24 | 25 | class Widget(TbModel): 26 | id: Id | str | None = None # in a DashboardDef, widgets have GUIDs for ids; other times they have full-on Id objects 27 | is_system_type: bool | None = Field(default=None, alias="isSystemType") 28 | bundle_alias: str | None = Field(default=None, alias="bundleAlias") 29 | type_alias: str | None = Field(default=None, alias="typeAlias") 30 | type: str 31 | name: str | None = Field(default=None, alias="title") 32 | size_x: float = Field(alias="sizeX") 33 | size_y: float = Field(alias="sizeY") 34 | config: Dict[str, Any] 35 | # index: str # TODO: Put in slots? 36 | 37 | def __str__(self) -> str: 38 | return f"Widget ({self.name}, {self.type})" 39 | 40 | 41 | class SubWidget(TbModel): # used within States <- Layouts <- Main <- Widgets 42 | id: str | None = None 43 | type: str | None = None 44 | size_x: float = Field(alias="sizeX") 45 | size_y: float = Field(alias="sizeY") 46 | mobile_height: Optional[int] = Field(default=None, alias="mobileHeight") # Sometimes missing from the TB json; default to None 47 | config: dict[str, Any] = {} 48 | row: int 49 | col: int 50 | type_full_fqn: str | None = Field(default=None, alias="typeFullFqn") 51 | 52 | # index: str # TODO: Put in slots? 53 | 54 | def __str__(self) -> str: 55 | return "SubWidget" 56 | # return f"SubWidget ({self.index})" 57 | 58 | 59 | class GridSetting(TbModel): # used within States <- Layouts <- Main <- GridSetting 60 | background_color: str = Field(alias="backgroundColor") # "#3e4b6b", 61 | color: str | None = None # "rgba(1, 1, 1, 0.87)", 62 | columns: int 63 | margin: int | None = None 64 | margins: List[int] | None = None # [10, 10], 65 | outer_margin: bool | None = Field(default=None, alias="outerMargin") 66 | background_size_mode: str = Field(alias="backgroundSizeMode") # "100%", 67 | auto_fill_height: bool | None = Field(default=None, alias="autoFillHeight") 68 | mobile_auto_fill_height: bool | None = Field(default=None, alias="mobileAutoFillHeight") 69 | mobile_row_height: int | None = Field(default=None, alias="mobileRowHeight") 70 | 71 | def __str__(self) -> str: 72 | return "GridSetting" 73 | 74 | 75 | class Layout(TbModel): # used within States <- Layouts <- Main <- Widgets 76 | widgets: dict[str, SubWidget] | list[SubWidget] # demo.thingsboard.io has list, Sensorbot has dict 77 | grid_settings: GridSetting = Field(alias="gridSettings") 78 | # index: str # new name of the layout 79 | 80 | def __str__(self) -> str: 81 | return f"Layout ({len(self.widgets)} widgets)" 82 | 83 | 84 | class State(TbModel): # referred to in "default", the only object nested inside of States 85 | name: str 86 | root: bool 87 | layouts: Dict[str, Layout] 88 | # widgets: list # skipping the step of going through widgets, this is the same as self.layouts["main"].widgets 89 | # index: str # TODO: Put in slots? 90 | 91 | def __str__(self) -> str: 92 | return f"State ({self.name})" 93 | 94 | 95 | class Filter(TbModel): 96 | type: Optional[str] 97 | resolve_multiple: Optional[bool] = Field(alias="resolveMultiple") 98 | single_entity: Optional[Id] = Field(default=None, alias="singleEntity") # Sometimes missing from json; default to None 99 | entity_type: Optional[str] = Field(default=None, alias="entityType") # Sometimes missing from json; default to None 100 | entity_name_filter: Optional[str] = Field(default=None, alias="entityNameFilter") 101 | 102 | def __str__(self) -> str: 103 | return f"Filter ({self.type}, {self.single_entity.id if self.single_entity else 'Undefined ID'})" 104 | 105 | 106 | class EntityAlias(TbModel): 107 | id: str | None = None 108 | alias: str 109 | filter: Filter | None = None 110 | 111 | # index: str 112 | 113 | def __str__(self) -> str: 114 | return f"Entity Alias ({self.alias}, {self.id})" 115 | 116 | 117 | class Setting(TbModel): 118 | state_controller_id: str = Field(alias="stateControllerId") 119 | show_title: bool = Field(alias="showTitle") 120 | show_dashboards_select: bool = Field(alias="showDashboardsSelect") 121 | show_entities_select: bool = Field(alias="showEntitiesSelect") 122 | show_dashboard_timewindow: bool = Field(alias="showDashboardTimewindow") 123 | show_dashboard_export: bool = Field(alias="showDashboardExport") 124 | toolbar_always_open: bool = Field(alias="toolbarAlwaysOpen") 125 | title_color: str | None = Field(default=None, alias="titleColor") 126 | 127 | def __str__(self) -> str: 128 | return f"Settings ({self.model_dump()})" 129 | 130 | 131 | class RealTime(TbModel): 132 | interval: int 133 | time_window_ms: int = Field(alias="timewindowMs") 134 | realtime_type: int | None = Field(default=None, alias="realtimeType") 135 | quick_interval: str | None = Field(default=None, alias="quickInterval") # e.g. "CURRENT_DAY" 136 | 137 | 138 | def __str__(self) -> str: 139 | return f"Real Time ({self.model_dump()})" 140 | 141 | 142 | class Aggregation(TbModel): 143 | type: str 144 | limit: int 145 | 146 | def __str__(self) -> str: 147 | return f"Aggregation ({self.model_dump()})" 148 | 149 | 150 | class FixedTimeWindow(TbModel): 151 | start_time_ms: int = Field(alias="startTimeMs") 152 | end_time_ms: int = Field(alias="endTimeMs") 153 | 154 | def __str__(self) -> str: 155 | return f"Fixed Time Window ({self.model_dump()})" 156 | 157 | 158 | class History(TbModel): 159 | history_type: int = Field(alias="historyType") 160 | interval: int = Field(alias="interval") 161 | time_window_ms: int = Field(alias="timewindowMs") 162 | fixed_time_window: FixedTimeWindow = Field(alias="fixedTimewindow") 163 | 164 | def __str__(self) -> str: 165 | return f"History ({self.model_dump()})" 166 | 167 | 168 | class TimeWindow(TbModel): 169 | display_value: str | None = Field(alias="displayValue", default=None) 170 | selected_tab: int | None = Field(alias="selectedTab", default=None) 171 | real_time: RealTime = Field(alias="realtime") 172 | aggregation: Aggregation 173 | history: History | None = None 174 | 175 | def __str__(self) -> str: 176 | return f"Time Window ({self.model_dump()})" 177 | 178 | 179 | class Configuration(TbModel): 180 | description: str | None = None 181 | widgets: Dict[str, Widget] | List[Widget] | None = None # demo.thingsboard.io has list, Sensorbot has dict 182 | states: Dict[str, State] | None = None 183 | device_aliases: Dict[str, EntityAlias] | None = Field(default=None, alias="deviceAliases") # I've seen both of these 184 | entity_aliases: Dict[str, EntityAlias] | None = Field(default=None, alias="entityAliases") 185 | time_window: TimeWindow | None = Field(default=None, alias="timewindow") 186 | filters: dict[str, Any] = Field(default={}) 187 | settings: Setting | None = None 188 | # name: str 189 | 190 | def __str__(self) -> str: 191 | return "Configuration" 192 | 193 | 194 | class DashboardHeader(TbObject): 195 | """ 196 | A Dashboard with no configuration object -- what you get from TB if you request a group of dashboards. 197 | """ 198 | # id, created_time provided by TbObject 199 | tenant_id: Id = Field(alias="tenantId") 200 | name: str | None = Field(default=None, alias="title") 201 | assigned_customers: list[CustomerId] | None = Field(alias="assignedCustomers") 202 | image: str | None 203 | mobile_hide: bool | None = Field(default=None, alias="mobileHide") 204 | mobile_order: int | None = Field(default=None, alias="mobileOrder") 205 | external_id: Id | None = Field(default=None, alias="externalId") 206 | 207 | # Other observed fields 208 | # resources 209 | # version: int 210 | # externalId: Id | None = Field(default=None, alias="externalId") 211 | 212 | 213 | def is_public(self) -> bool: 214 | """ 215 | Return True if dashboard is owned by the public user False otherwise 216 | """ 217 | if self.assigned_customers is None: 218 | return False 219 | 220 | for c in self.assigned_customers: 221 | if c.public: 222 | return True 223 | 224 | return False 225 | 226 | 227 | def assign_to(self, customer: "Customer") -> None: 228 | from .Customer import CustomerId 229 | 230 | dashboard_id = self.id.id 231 | customer_id = customer.id.id 232 | resp = self.tbapi.post(f"/api/customer/{customer_id}/dashboard/{dashboard_id}", None, f"Could not assign dashboard '{dashboard_id}' to customer '{customer_id}'") 233 | 234 | # Update customer object with new assignments 235 | self.assigned_customers = [] 236 | for cust_data in resp["assignedCustomers"]: 237 | self.assigned_customers.append(CustomerId(**cust_data)) 238 | 239 | 240 | def get_customers(self) -> List["Customer"]: 241 | """ Returns a list of all customers assigned to the device, if any. """ 242 | 243 | custlist: List["Customer"] = [] 244 | for custid in self.assigned_customers or []: # Handle None 245 | cust = self.tbapi.get_customer_by_id(custid) 246 | if cust: 247 | custlist.append(cust) 248 | 249 | return custlist 250 | 251 | 252 | def make_public(self) -> None: 253 | """ Assigns dashboard to the public customer, which is how TB makes things public. """ 254 | if self.is_public(): 255 | return 256 | 257 | self.tbapi.post(f"/api/customer/public/dashboard/{self.id.id}", None, f"Error assigning dash '{self.id.id}' to public customer") 258 | # self.customer_id = Id(**obj["customerId"]) 259 | 260 | public_id = self.tbapi.get_public_user_id() 261 | assert public_id # Should never because we now have something assigned to the public user 262 | if self.assigned_customers is None: 263 | self.assigned_customers = [] 264 | 265 | self.assigned_customers.append(public_id) 266 | 267 | 268 | def make_private(self) -> None: 269 | """ Removes the public customer, which will make it visible only to assigned customers. """ 270 | if not self.is_public(): 271 | return 272 | 273 | self.tbapi.delete(f"/api/customer/public/dashboard/{self.id.id}", f"Error removing the public customer from dash '{self.id.id}'") 274 | # self.customer_id = Id(**obj["customerId"]) 275 | 276 | public_id = self.tbapi.get_public_user_id() 277 | assert public_id # Should never because we now have something assigned to the public user 278 | assert self.assigned_customers # If we're public, we have at least one customer, so self.assigned_customers should never be empty or None 279 | self.assigned_customers.remove(public_id) 280 | 281 | 282 | def get_public_url(self) -> str | None: 283 | if not self.is_public(): 284 | return None 285 | 286 | dashboard_id = self.id.id 287 | 288 | public_id = self.tbapi.get_public_user_id() 289 | assert public_id # Should always be true because this item is public so the public user should exist 290 | 291 | public_guid = public_id.id.id 292 | 293 | return f"{self.tbapi.mothership_url}/dashboard/{dashboard_id}?publicId={public_guid}" 294 | 295 | 296 | def get_dashboard(self): 297 | dash_id = self.id.id 298 | obj = self.tbapi.get(f"/api/dashboard/{dash_id}", f"Error retrieving dashboard definition for '{dash_id}'") 299 | return Dashboard.model_validate(obj | {"tbapi": self.tbapi}) 300 | 301 | 302 | def update(self): 303 | """ 304 | Writes object back to the database. Use this if you want to save any modified properties. 305 | """ 306 | return self.tbapi.post("/api/dashboard", self.model_dump_json(by_alias=True), f"Error updating '{self.id}'") 307 | 308 | 309 | def delete(self) -> bool: 310 | """ 311 | Returns True if dashboard was deleted, False if it did not exist 312 | """ 313 | dashboard_id = self.id.id 314 | return self.tbapi.delete(f"/api/dashboard/{dashboard_id}", f"Error deleting dashboard '{dashboard_id}'") 315 | 316 | 317 | class Dashboard(DashboardHeader): 318 | """ Extends Dashboard by adding a configuration. """ 319 | configuration: Configuration | None # Empty dashboards will have no configuration 320 | 321 | 322 | # # Rebuild models to resolve forward references 323 | # DashboardHeader.model_rebuild() 324 | # Dashboard.model_rebuild() 325 | -------------------------------------------------------------------------------- /thingsboard_api_tools/Device.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | from typing import Optional, Dict, List, Any, Union, Iterable, TYPE_CHECKING 19 | from datetime import datetime 20 | from enum import Enum 21 | from pydantic import Field 22 | 23 | from .TbModel import TbObject, Id 24 | from .HasAttributes import HasAttributes 25 | from .DeviceProfile import DeviceProfile, DeviceProfileInfo 26 | 27 | 28 | if TYPE_CHECKING: 29 | from .TbModel import TbApi 30 | from .Customer import Customer 31 | 32 | 33 | Timestamp = Union[datetime, float] 34 | 35 | 36 | class AggregationType(Enum): 37 | MIN = "MIN" 38 | MAX = "MAX" 39 | AVG = "AVG" 40 | SUM = "SUM" 41 | COUNT = "COUNT" 42 | NONE = "NONE" 43 | 44 | 45 | class Device(TbObject, HasAttributes): 46 | additional_info: Optional[Dict[str, Any]] = Field(default={}, alias="additionalInfo") 47 | tenant_id: Id = Field(alias="tenantId") 48 | customer_id: Id = Field(alias="customerId") 49 | name: Optional[str] 50 | type: Optional[str] 51 | label: Optional[str] 52 | device_profile_id: Id = Field(alias="deviceProfileId") 53 | software_id: Optional[str] = Field(alias="softwareId") 54 | firmware_id: Optional[str] = Field(alias="firmwareId") 55 | 56 | _device_token: Optional[str] = None 57 | 58 | def __type_hints__(self, device_token: str): 59 | """ Dummy method to annotate the instance attribute types """ 60 | self._device_token = device_token 61 | 62 | 63 | def __init__(self, tbapi: "TbApi", *args: List[Any], **kwargs: Dict[str, Any]): 64 | """ Create an initializer to handle our slot fields; other fields handled automatically by Pydantic. """ 65 | super().__init__(tbapi=tbapi, *args, **kwargs) 66 | object.__setattr__(self, "device_token", None) # type: str 67 | pass 68 | 69 | 70 | def delete(self) -> bool: 71 | """ Returns True if device was deleted, False if it did not exist """ 72 | return self.tbapi.delete(f"/api/device/{self.id.id}", f"Error deleting device '{self.id.id}'") 73 | 74 | 75 | def assign_to(self, customer: "Customer") -> None: 76 | if self.customer_id != customer.id: 77 | obj = self.tbapi.post(f"/api/customer/{customer.id.id}/device/{self.id.id}", None, f"Error assigning device '{self.id.id}' to customer {customer}") 78 | self.customer_id = Id.model_validate(obj["customerId"]) 79 | 80 | 81 | def get_customer(self) -> Optional["Customer"]: 82 | """ Returns the customer assigned to the device, or None if the device is unassigned. """ 83 | return self.tbapi.get_customer_by_id(self.customer_id) 84 | 85 | 86 | def make_public(self) -> None: 87 | """ Assigns device to the public customer, which is how TB makes devices public. """ 88 | if not self.is_public(): 89 | obj = self.tbapi.post(f"/api/customer/public/device/{self.id.id}", None, f"Error assigning device '{self.id.id}' to public customer") 90 | self.customer_id = Id.model_validate(obj["customerId"]) 91 | 92 | 93 | def is_public(self) -> bool: 94 | """ Return True if device is owned by the public user, False otherwise """ 95 | public_id = self.tbapi.get_public_user_id() 96 | if not public_id: 97 | return False 98 | 99 | return public_id == self.customer_id 100 | 101 | 102 | def get_profile(self) -> DeviceProfile: 103 | return self.tbapi.get_device_profile_by_id(self.device_profile_id) 104 | 105 | 106 | def get_profile_info(self) -> DeviceProfileInfo: 107 | return self.tbapi.get_device_profile_info_by_id(self.device_profile_id) 108 | 109 | 110 | def get_telemetry( 111 | self, 112 | telemetry_keys: Union[str, Iterable[str]], 113 | start_ts: Optional[Timestamp] = None, 114 | end_ts: Optional[Timestamp] = None, 115 | interval: Optional[int] = None, 116 | limit: int = 100, # Just to keep things sane 117 | agg: AggregationType = AggregationType.NONE, 118 | ) -> Dict[str, List[Dict[str, Any]]]: 119 | """ 120 | telemetry_keys: Pass a single key or a list of keys 121 | Note: Returns a sane amount of data by default, in same shape as get_latest_telemetry() 122 | """ 123 | if isinstance(telemetry_keys, str): 124 | keys = telemetry_keys 125 | else: 126 | keys = ",".join(telemetry_keys) 127 | 128 | # Don't include these in the signature because datetime.now() gets evaluated once when function is first called, then reused after that. 129 | # It's lame, but it's the way default values are managed in Python. Blame Guido. 130 | # Same principle as this: https://web.archive.org/web/20201002220217/http://effbot.org/zone/default-values.htm 131 | if start_ts is None: 132 | start_ts = 0 133 | if end_ts is None: 134 | end_ts = datetime.now() 135 | 136 | 137 | start_ts = prepare_ts(start_ts) 138 | end_ts = prepare_ts(end_ts) 139 | 140 | # These are all optional parameters, strictly speaking 141 | interval_clause = f"&interval={interval}" if interval else "" 142 | limit_caluse = f"&limit={limit}" if limit else "" 143 | agg_clause = f"&agg={agg.value}" 144 | use_strict_datatypes_clause = "&useStrictDataTypes=true" # Fixes bug of getting back strings as numbers 145 | 146 | clauses = interval_clause + limit_caluse + agg_clause + use_strict_datatypes_clause 147 | params = f"/api/plugins/telemetry/DEVICE/{self.id.id}/values/timeseries?keys={keys}&startTs={start_ts}&endTs={end_ts}{clauses}" 148 | 149 | error_message = f"Error retrieving telemetry for device '{self}' with date range '{start_ts}-{end_ts}' and keys '{keys}'" 150 | 151 | return self.tbapi.get(params, error_message) 152 | # https://demo.thingsboard.io/swagger-ui.html#/telemetry-controller/getTimeseriesUsingGET 153 | 154 | 155 | def send_telemetry(self, data: Dict[str, Any], ts: Optional[Timestamp | int] = None): 156 | if not data: 157 | return 158 | 159 | if ts is not None: 160 | data = {"ts": prepare_ts(ts), "values": data} 161 | 162 | return self.tbapi.post(f"/api/v1/{self.token}/telemetry", data, f"Error sending telemetry for device '{self.name}'") 163 | # scope = "LATEST_TELEMETRY" 164 | # return self.tbapi.post(f"/api/plugins/telemetry/DEVICE/{self.token}/timeseries/{scope}", data, f"Error sending telemetry for device '{self.name}'") 165 | 166 | # /api/plugins/telemetry/{entityType}/{entityId}/timeseries/{scope} 167 | # https://demo.thingsboard.io/swagger-ui.html#/telemetry-controller/saveEntityAttributesV1UsingPOST 168 | 169 | 170 | def get_telemetry_keys(self) -> List[str]: 171 | return self.tbapi.get(f"/api/plugins/telemetry/DEVICE/{self.id.id}/keys/timeseries", f"Error retrieving telemetry keys for device '{self.id.id}'") 172 | 173 | 174 | def get_latest_telemetry(self, telemetry_keys: str | Iterable[str]) -> Dict[str, List[Dict[str, Any]]]: 175 | """ 176 | Pass a single key, a stringified comma-separate list, a list object, or a tuple 177 | get_latest_telemetry(['datum_1', 'datum_2']) ==> 178 | {'datum_1': [{'ts': 1595897301000, 'value': '555'}], 'datum_2': [{'ts': 1595897301000, 'value': '666'}]} 179 | 180 | """ 181 | if isinstance(telemetry_keys, str): 182 | keys = telemetry_keys 183 | else: 184 | keys = ",".join(telemetry_keys) 185 | 186 | use_strict_datatypes_clause = "&useStrictDataTypes=true" # Fixes bug of getting back strings as numbers 187 | url = f"/api/plugins/telemetry/DEVICE/{self.id.id}/values/timeseries?keys={keys}{use_strict_datatypes_clause}" 188 | 189 | return self.tbapi.get(url, f"Error retrieving latest telemetry for device '{self.id.id}' with keys '{keys}'") 190 | 191 | 192 | def delete_all_telemetry(self, keys: Union[str, List[str]]): 193 | """ Danger, Will Robinson! Deletes all device data for the specified key(s). """ 194 | if isinstance(keys, str): 195 | keys = [keys] 196 | 197 | params = f"keys={','.join(keys)}&deleteAllDataForKeys=True" 198 | 199 | return self.tbapi.delete(f"/api/plugins/telemetry/DEVICE/{self.id.id}/timeseries/delete?{params}", f"Error deleting telemetry for device '{self.id.id}' with params '{params}'") 200 | # https://demo.thingsboard.io/swagger-ui.html#/telemetry-controller/deleteEntityTimeseriesUsingDELETE 201 | 202 | 203 | def delete_telemetry( 204 | self, 205 | keys: Union[str, List[str]], 206 | start_ts: Timestamp, 207 | end_ts: Timestamp, 208 | rewrite_latest_if_deleted: bool = False, 209 | ) -> bool: 210 | """ 211 | Delete specified telemetry between start_ts and end_ts. 212 | rewrite_latest_if_deleted: True if the server should update ts_kv_latest; False if the server should obliterate ts_kv_latest 213 | by writing a record with current timestamp and a value of None. 214 | Returns True if request succeeded, whether or not telemetry was actually deleted, False if there was a problem. 215 | """ 216 | if isinstance(keys, str): 217 | keys = [keys] 218 | 219 | start_ts = prepare_ts(start_ts) 220 | end_ts = prepare_ts(end_ts) 221 | 222 | params = f"keys={','.join(keys)}&startTs={start_ts}&endTs={end_ts}&rewriteLatestIfDeleted={rewrite_latest_if_deleted}&deleteAllDataForKeys=False" 223 | 224 | return self.tbapi.delete(f"/api/plugins/telemetry/DEVICE/{self.id.id}/timeseries/delete?{params}", f"Error deleting telemetry for device '{self.id.id}' with params '{params}'") 225 | # https://demo.thingsboard.io/swagger-ui.html#/telemetry-controller/deleteEntityTimeseriesUsingDELETE 226 | 227 | 228 | @property 229 | def token(self) -> str: 230 | """ Returns the device's secret token from the server and caches it for reuse. """ 231 | if self._device_token is None: 232 | obj = self.tbapi.get(f"/api/device/{self.id.id}/credentials", f"Error retreiving device_key for device '{self}'") 233 | self._device_token = obj["credentialsId"] 234 | 235 | if self._device_token is None: 236 | raise Exception(f"Could not find token for device '{self}'") 237 | 238 | return self._device_token 239 | 240 | 241 | def update(self): 242 | """ 243 | Writes object back to the database. Use this if you want to save any modified properties. 244 | """ 245 | return self.tbapi.post("/api/device", self.model_dump_json(by_alias=True), f"Error updating '{self.id.id}'") 246 | # https://demo.thingsboard.io/swagger-ui.html#/device-controller/saveDeviceUsingPOST 247 | 248 | 249 | def prepare_ts(ts: Timestamp) -> int: 250 | if isinstance(ts, datetime): 251 | ts = ts.timestamp() * 1000 252 | 253 | return int(ts) 254 | -------------------------------------------------------------------------------- /thingsboard_api_tools/DeviceProfile.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | from typing import Any 19 | from pydantic import Field 20 | 21 | try: 22 | from .TbModel import TbObject, Id 23 | from .HasAttributes import HasAttributes 24 | except (ModuleNotFoundError, ImportError): 25 | from TbModel import TbObject, Id 26 | from HasAttributes import HasAttributes 27 | 28 | 29 | class DeviceProfileInfo(TbObject, HasAttributes): 30 | name: str # Default appars to be "device_type" 31 | image: str | None 32 | default_dashboard_id: Id | None 33 | type: str | None # Default appears to be "DEFAULT" 34 | transport_type: str = Field(alias="transportType") # Default appears to be "DEFAULT" 35 | default_dashboard_id: Id | None = Field(alias="defaultDashboardId") 36 | 37 | 38 | class DeviceProfile(DeviceProfileInfo): 39 | tenant_id: Id | None = Field(alias="tenantId") 40 | description: str # Default appears to be "Default device profile" 41 | provision_type: str = Field(alias="provisionType") # Default appears to be "DISABLED" 42 | default_queue_name: str | None = Field(alias="defaultQueueName") 43 | provision_device_key: str | None = Field(alias="provisionDeviceKey") 44 | firmware_id: Id | None = Field(alias="firmwareId") 45 | software_id: Id | None = Field(alias="softwareId") 46 | default_rule_chain_id: Id | None = Field(alias="defaultRuleChainId") 47 | default_queue_name: str | None = Field(alias="defaultQueueName") 48 | default_edge_rule_chain_id: Id | None = Field(alias="defaultEdgeRuleChainId") 49 | external_id: Id | None = Field(alias="externalId") 50 | profile_data: dict[str, Any] = Field(alias="profileData") # TODO: Needs to be built out 51 | 52 | # Sample profile_data from swagger docs 53 | # "profileData": { 54 | # "configuration": {}, 55 | # "transportConfiguration": {}, 56 | # "provisionConfiguration": { 57 | # "provisionDeviceSecret": "string" 58 | # }, 59 | # "alarms": [ 60 | # { 61 | # "id": "highTemperatureAlarmID", 62 | # "alarmType": "High Temperature Alarm", 63 | # "createRules": { 64 | # "additionalProp1": { 65 | # "condition": { 66 | # "condition": [ 67 | # { 68 | # "key": { 69 | # "type": "TIME_SERIES", 70 | # "key": "temp" 71 | # }, 72 | # "valueType": "NUMERIC", 73 | # "value": {}, 74 | # "predicate": {} 75 | # } 76 | # ], 77 | # "spec": {} 78 | # }, 79 | # "schedule": { 80 | # "dynamicValue": { 81 | # "inherit": true, 82 | # "sourceAttribute": "string", 83 | # "sourceType": "CURRENT_CUSTOMER" 84 | # }, 85 | # "type": "ANY_TIME" 86 | # }, 87 | # "alarmDetails": "string", 88 | # "dashboardId": { 89 | # "id": "784f394c-42b6-435a-983c-b7beff2784f9", 90 | # "entityType": "DASHBOARD" 91 | # } 92 | # }, 93 | # "additionalProp2": { 94 | # "condition": { 95 | # "condition": [ 96 | # { 97 | # "key": { 98 | # "type": "TIME_SERIES", 99 | # "key": "temp" 100 | # }, 101 | # "valueType": "NUMERIC", 102 | # "value": {}, 103 | # "predicate": {} 104 | # } 105 | # ], 106 | # "spec": {} 107 | # }, 108 | # "schedule": { 109 | # "dynamicValue": { 110 | # "inherit": true, 111 | # "sourceAttribute": "string", 112 | # "sourceType": "CURRENT_CUSTOMER" 113 | # }, 114 | # "type": "ANY_TIME" 115 | # }, 116 | # "alarmDetails": "string", 117 | # "dashboardId": { 118 | # "id": "784f394c-42b6-435a-983c-b7beff2784f9", 119 | # "entityType": "DASHBOARD" 120 | # } 121 | # }, 122 | # "additionalProp3": { 123 | # "condition": { 124 | # "condition": [ 125 | # { 126 | # "key": { 127 | # "type": "TIME_SERIES", 128 | # "key": "temp" 129 | # }, 130 | # "valueType": "NUMERIC", 131 | # "value": {}, 132 | # "predicate": {} 133 | # } 134 | # ], 135 | # "spec": {} 136 | # }, 137 | # "schedule": { 138 | # "dynamicValue": { 139 | # "inherit": true, 140 | # "sourceAttribute": "string", 141 | # "sourceType": "CURRENT_CUSTOMER" 142 | # }, 143 | # "type": "ANY_TIME" 144 | # }, 145 | # "alarmDetails": "string", 146 | # "dashboardId": { 147 | # "id": "784f394c-42b6-435a-983c-b7beff2784f9", 148 | # "entityType": "DASHBOARD" 149 | # } 150 | # } 151 | # }, 152 | # "clearRule": { 153 | # "condition": { 154 | # "condition": [ 155 | # { 156 | # "key": { 157 | # "type": "TIME_SERIES", 158 | # "key": "temp" 159 | # }, 160 | # "valueType": "NUMERIC", 161 | # "value": {}, 162 | # "predicate": {} 163 | # } 164 | # ], 165 | # "spec": {} 166 | # }, 167 | # "schedule": { 168 | # "dynamicValue": { 169 | # "inherit": true, 170 | # "sourceAttribute": "string", 171 | # "sourceType": "CURRENT_CUSTOMER" 172 | # }, 173 | # "type": "ANY_TIME" 174 | # }, 175 | # "alarmDetails": "string", 176 | # "dashboardId": { 177 | # "id": "784f394c-42b6-435a-983c-b7beff2784f9", 178 | # "entityType": "DASHBOARD" 179 | # } 180 | # }, 181 | # "propagate": true, 182 | # "propagateToOwner": true, 183 | # "propagateToTenant": true, 184 | # "propagateRelationTypes": [ 185 | # "string" 186 | # ] 187 | # } 188 | # ] 189 | # }, 190 | -------------------------------------------------------------------------------- /thingsboard_api_tools/EntityType.py: -------------------------------------------------------------------------------- 1 | # Not used by this lib,, but still referenced by some downstream projects 2 | 3 | class EntityType(): 4 | CUSTOMER = "CUSTOMER" 5 | DASHBOARD = "DASHBOARD" 6 | DEVICE = "DEVICE" 7 | DEVICE_PROFILE = "DEVICE_PROFILE" 8 | TENANT = "TENANT" 9 | -------------------------------------------------------------------------------- /thingsboard_api_tools/HasAttributes.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Iterable, Dict, Any 2 | 3 | from .TbModel import Attributes, Id 4 | from .TbApi import TbApi 5 | 6 | 7 | class HasAttributes(): 8 | """ Mixin class to support all attribute methods. """ # TODO: Should be protocol? 9 | 10 | tbapi: TbApi # Provide elsewhere 11 | id: Id # It will just be here... 12 | 13 | 14 | def set_server_attributes(self, attributes: Attributes | dict[str, Any]): 15 | """ 16 | Posts the attributes provided (use dict format) to the server in the Server scope 17 | """ 18 | from .TbModel import TbObject 19 | 20 | assert isinstance(self, TbObject) 21 | 22 | 23 | self._set_attributes(attributes, Attributes.Scope.SERVER) 24 | 25 | 26 | def get_server_attributes(self) -> Attributes: 27 | """ Returns a list of the device's attributes in a the Server scope. """ 28 | from .TbModel import TbObject 29 | 30 | assert isinstance(self, TbObject) 31 | 32 | 33 | return self._get_attributes(Attributes.Scope.SERVER) 34 | 35 | 36 | def delete_server_attributes(self, attributes: Union[str, Iterable[str]]) -> bool: 37 | """ Pass an attribute name or a list of attributes to be deleted from the specified scope """ 38 | from .TbModel import TbObject 39 | 40 | assert isinstance(self, TbObject) 41 | 42 | return self._delete_attributes(attributes, Attributes.Scope.SERVER) 43 | 44 | 45 | def delete_attributes(self, attributes: Union[str, Iterable[str]], scope: Attributes.Scope) -> bool: 46 | """ Pass an attribute name or a list of attributes to be deleted from the specified scope """ 47 | from .TbModel import TbObject 48 | 49 | assert isinstance(self, TbObject) 50 | 51 | return self._delete_attributes(attributes, scope) 52 | 53 | 54 | # Get attributes from the server 55 | def get_shared_attributes(self) -> Attributes: 56 | """ Returns a list of the device's attributes in a the Shared scope. """ 57 | from .TbModel import TbObject 58 | 59 | assert isinstance(self, TbObject) 60 | 61 | return self._get_attributes(Attributes.Scope.SHARED) 62 | 63 | 64 | # Set attributes on the server 65 | def set_shared_attributes(self, attributes: Union[Attributes, Dict[str, Any]]): 66 | """ 67 | Posts the attributes provided (use dict format) to the server in the Shared scope 68 | """ 69 | from .TbModel import TbObject 70 | 71 | assert isinstance(self, TbObject) 72 | 73 | self._set_attributes(attributes, Attributes.Scope.SHARED) 74 | 75 | 76 | # Delete attributes from the server 77 | def delete_shared_attributes(self, attributes: Union[str, Iterable[str]]) -> bool: 78 | """ Pass an attribute name or a list of attributes to be deleted from the specified scope """ 79 | from .TbModel import TbObject 80 | 81 | assert isinstance(self, TbObject) 82 | 83 | return self._delete_attributes(attributes, Attributes.Scope.SHARED) 84 | 85 | 86 | def get_client_attributes(self) -> Attributes: 87 | """ Returns a list of the device's attributes in a the Client scope. """ 88 | from .TbModel import TbObject 89 | 90 | assert isinstance(self, TbObject) 91 | 92 | return self._get_attributes(Attributes.Scope.CLIENT) 93 | 94 | 95 | def delete_client_attributes(self, attributes: Union[str, Iterable[str]]) -> bool: 96 | """ Pass an attribute name or a list of attributes to be deleted from the specified scope """ 97 | from .TbModel import TbObject 98 | 99 | assert isinstance(self, TbObject) 100 | 101 | return self._delete_attributes(attributes, Attributes.Scope.CLIENT) 102 | 103 | 104 | def _set_attributes(self, attributes: Union["Attributes", dict[str, Any]], scope: Attributes.Scope): 105 | """ Posts the attributes provided (use dict format) to the server at a specified scope """ 106 | # from .TbModel import Id 107 | 108 | if isinstance(attributes, Attributes): 109 | attributes = attributes.as_dict() 110 | 111 | id = self.id 112 | 113 | url = f"/api/plugins/telemetry/{id.entity_type}/{id.id}/{scope.value}" 114 | return self.tbapi.post(url, attributes, f"Error setting {scope.value} attributes for '{id}'") 115 | 116 | 117 | def _get_attributes(self, scope: Attributes.Scope): 118 | """ 119 | Returns a list of the device's attributes in the specified scope. 120 | Looks like [{'key': 'active', 'lastUpdateTs': 1595969455329, 'value': False}, ...] 121 | """ 122 | id = self.id 123 | 124 | url = f"/api/plugins/telemetry/{id.entity_type}/{id.id}/values/attributes/{scope.value}" 125 | attribute_data = self.tbapi.get(url, f"Error retrieving {scope.value} attributes for '{id}'") 126 | 127 | return Attributes(attribute_data, scope) 128 | 129 | 130 | def _delete_attributes(self, attributes: str | Iterable[str], scope: Attributes.Scope) -> bool: 131 | """ 132 | Pass an attribute name or a list of attributes to be deleted from the specified scope; 133 | returns True if operation generally, succeeded, even if attribute we're deleting didn't 134 | exist. 135 | """ 136 | id = self.id 137 | 138 | if not isinstance(attributes, str): 139 | attributes = ",".join(attributes) 140 | 141 | url = f"/api/plugins/telemetry/{id.entity_type}/{id.id}/{scope.value}?keys={attributes}" 142 | return self.tbapi.delete(url, f"Error deleting {scope.value} attributes for '{id}'") 143 | -------------------------------------------------------------------------------- /thingsboard_api_tools/TbApi.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | from typing import Optional, Any, Union, Type, TypeVar, TYPE_CHECKING 19 | 20 | import json as Json 21 | import requests 22 | import time 23 | from http import HTTPStatus 24 | 25 | if TYPE_CHECKING: 26 | from .Customer import Customer, CustomerId 27 | from .Dashboard import Dashboard 28 | from .Device import Device 29 | from .DeviceProfile import DeviceProfile, DeviceProfileInfo 30 | from .TbModel import Id, TbObject 31 | 32 | MINUTES = 60 33 | 34 | 35 | T = TypeVar("T", bound="TbObject") # T can be any subclass of TbObject 36 | 37 | class TbApi: 38 | NULL_GUID = "13814000-1dd2-11b2-8080-808080808080" # From EntityId.java in TB codebase 39 | 40 | def __init__(self, url: str, username: str, password: str, token_timeout: float = 10 * MINUTES): 41 | self.mothership_url: str = url 42 | self.username: str = username 43 | self.password: str = password 44 | self.token_timeout: float = token_timeout # In seconds (epoch time, actually) 45 | 46 | self.token_time: float = 0 47 | self.token: str | None = None 48 | 49 | self.verbose: bool = False 50 | self.public_user_id: "CustomerId | None" = None 51 | 52 | 53 | def get_token(self) -> str: 54 | """ 55 | Fetches and return an access token needed by most other methods; caches tokens for reuse 56 | """ 57 | # If we already have a valid token, use it 58 | if self.token is not None and time.time() - self.token_time < self.token_timeout: 59 | return self.token 60 | 61 | data = '{"username":"' + self.username + '", "password":"' + self.password + '"}' 62 | headers = {"Accept": "application/json", "Content-Type": "application/json"} 63 | # json = post("/api/auth/login", None, data, "Error requesting token") 64 | 65 | url = self.mothership_url + "/api/auth/login" 66 | try: 67 | response = requests.post(url, data=data, headers=headers) 68 | except requests.ConnectTimeout as ex: 69 | ex.args = (f"Could not connect to server (url='{url}'). Is it up?", *ex.args) 70 | raise 71 | 72 | 73 | self.validate_response(response, "Error requesting token") 74 | 75 | self.token = Json.loads(response.text)["token"] 76 | if not self.token: 77 | raise TokenError("No token received from server") 78 | 79 | self.token_time = time.time() 80 | 81 | return self.token 82 | 83 | 84 | def get_tenant_assets(self): 85 | """ 86 | Returns a list of all assets for current tenant 87 | """ 88 | return self.get_paged("/api/tenant/assets", "Error retrieving assets for tenant") 89 | 90 | 91 | def get_tenant_devices(self): 92 | """ 93 | Returns a list of all ecustdevices for current tenant 94 | """ 95 | return self.get_paged("/api/tenant/devices", "Error retrieving devices for tenant") 96 | 97 | 98 | def get_public_user_id(self) -> "CustomerId | None": 99 | """ 100 | Returns Id of public customer, or None if there is none. Caches value for future use. 101 | """ 102 | if not self.public_user_id: 103 | public_customer = self.get_customer_by_name("Public") 104 | 105 | if not public_customer: 106 | # This could happen if nothing has been yet set to public 107 | return None 108 | 109 | self.public_user_id = public_customer.customer_id 110 | 111 | return self.public_user_id 112 | 113 | 114 | def create_dashboard(self, name: str, template: Optional["Dashboard"] = None, id: Optional["Id"] = None) -> "Dashboard": 115 | """ 116 | Returns a Dashboard (including configuration) 117 | """ 118 | 119 | from .Dashboard import Dashboard 120 | 121 | 122 | data: dict[str, Any] = { 123 | "title": name, 124 | } 125 | 126 | if template and template.configuration: 127 | data["configuration"] = template.configuration.model_dump(by_alias=True) 128 | 129 | if id: 130 | data["id"] = id.model_dump(by_alias=True) 131 | 132 | # Update the configuration 133 | obj = self.post("/api/dashboard", data, "Error creating new dashboard") 134 | return Dashboard.model_validate(obj | {"tbapi": self}) 135 | 136 | 137 | def get_all_dashboard_headers(self): 138 | """ 139 | Return a list of all dashboards in the system 140 | """ 141 | from .Dashboard import DashboardHeader 142 | 143 | all_results = self.get_paged("/api/tenant/dashboards", "Error fetching list of all dashboards") 144 | return self.tb_objects_from_list(all_results, DashboardHeader) 145 | 146 | 147 | def get_dashboard_headers_by_name(self, dash_name_prefix: str): 148 | """ 149 | Returns a list of all dashes starting with the specified name 150 | """ 151 | from .Dashboard import DashboardHeader 152 | 153 | url = f"/api/tenant/dashboards?textSearch={dash_name_prefix}" 154 | objs = self.get_paged(url, f"Error retrieving dashboards starting with '{dash_name_prefix}'") 155 | 156 | dashes: list[DashboardHeader] = [] 157 | 158 | for obj in objs: 159 | dashes.append(DashboardHeader.model_validate(obj | {"tbapi": self})) 160 | 161 | return dashes 162 | 163 | 164 | def get_dashboard_by_name(self, dash_name: str): 165 | """ Returns dashboard with specified name, or None if we can't find one """ 166 | headers = self.get_dashboard_headers_by_name(dash_name) 167 | for header in headers: 168 | if header.name == dash_name: 169 | return header.get_dashboard() 170 | 171 | return None 172 | 173 | 174 | def get_dashboard_by_id(self, dash_id: Union["Id", str]): 175 | return self.get_dashboard_header_by_id(dash_id).get_dashboard() 176 | 177 | 178 | def get_dashboard_header_by_id(self, dash_id: Union["Id", str]): 179 | """ 180 | Retrieve dashboard by id 181 | """ 182 | from .Dashboard import DashboardHeader 183 | from .TbModel import Id 184 | 185 | if isinstance(dash_id, Id): 186 | dash_id = dash_id.id 187 | # otherwise, assume dash_id is a guid 188 | 189 | obj = self.get(f"/api/dashboard/info/{dash_id}", f"Error retrieving dashboard for '{dash_id}'") 190 | return DashboardHeader.model_validate(obj | {"tbapi": self}) 191 | 192 | 193 | def create_customer( 194 | self, 195 | name: str, 196 | # tenant_id: Id, # Appears unsupported? Can be omitted. 197 | address: Optional[str] = None, 198 | address2: Optional[str] = None, 199 | city: Optional[str] = None, 200 | state: Optional[str] = None, 201 | zip: Optional[str] = None, 202 | country: Optional[str] = None, 203 | email: Optional[str] = None, 204 | phone: Optional[str] = None, 205 | additional_info: dict[str, Any] = {}, 206 | server_attributes: dict[str, Any] = {}, 207 | ): 208 | """ Factory method. """ 209 | from .Customer import Customer 210 | 211 | data: dict[str, Any] = { 212 | "title": name, 213 | # "tenantId": tenant_id.model_dump(), # Can't get this to work 214 | "address": address, 215 | "address2": address2, 216 | "city": city, 217 | "state": state, 218 | "zip": zip, 219 | "country": country, 220 | "email": email, 221 | "phone": phone, 222 | "additionalInfo": additional_info, 223 | } 224 | 225 | obj = self.post("/api/customer", Json.dumps(data), "Error creating customer") 226 | customer = Customer.model_validate(obj | {"tbapi": self}) 227 | 228 | if server_attributes: 229 | customer.set_server_attributes(server_attributes) 230 | 231 | return customer 232 | 233 | 234 | def get_customer_by_id(self, cust_id: Union["CustomerId", "Id", str]): 235 | """ 236 | Returns an instantiated Customer object cust_id can be either an Id object or a guid. If the passed id is the NULL_GUID, 237 | return None. 238 | """ 239 | from .Customer import Customer, CustomerId 240 | from .TbModel import Id 241 | 242 | if isinstance(cust_id, CustomerId): 243 | cust_id = cust_id.id.id 244 | elif isinstance(cust_id, Id): 245 | cust_id = cust_id.id 246 | # otherwise, assume cust_id is a guid 247 | 248 | if cust_id == TbApi.NULL_GUID: 249 | return None 250 | 251 | obj = self.get(f"/api/customer/{cust_id}", f"Could not retrieve Customer with id '{cust_id}'") 252 | return Customer.model_validate(obj | {"tbapi": self}) 253 | 254 | 255 | def get_customers_by_name(self, cust_name_prefix: str): 256 | """ 257 | Returns a list of all customers starting with the specified name 258 | """ 259 | from .Customer import Customer 260 | 261 | cust_datas = self.get_paged(f"/api/customers?textSearch={cust_name_prefix}", f"Error retrieving customers with names starting with '{cust_name_prefix}'") 262 | 263 | customers: list[Customer] = [] 264 | for cust_data in cust_datas: 265 | # Sometimes this comes in as a dict, sometimes as a string. Not sure why. 266 | if cust_data["additionalInfo"] is not None and not isinstance(cust_data["additionalInfo"], dict): 267 | cust_data["additionalInfo"] = Json.loads(cust_data["additionalInfo"]) 268 | 269 | customers.append(Customer.model_validate(cust_data | {"tbapi": self})) 270 | return customers 271 | 272 | 273 | def get_customer_by_name(self, cust_name: str): 274 | """ 275 | Returns a customer with the specified name, or None if we can't find one 276 | """ 277 | customers = self.get_customers_by_name(cust_name) 278 | return _exact_match(cust_name, customers) 279 | 280 | 281 | def get_all_customers(self): 282 | """ 283 | Return a list of all customers in the system 284 | """ 285 | from .Customer import Customer 286 | 287 | all_results = self.get_paged("/api/customers", "Error fetching list of all customers") 288 | return self.tb_objects_from_list(all_results, Customer) 289 | 290 | 291 | def get_all_tenants(self): 292 | """ 293 | Return a list of all tenants in the system 294 | """ 295 | from .Tenant import Tenant 296 | 297 | all_results = self.get_paged("/api/tenants", "Error fetching list of all tenants") 298 | return self.tb_objects_from_list(all_results, Tenant) 299 | 300 | 301 | def create_device( 302 | self, 303 | name: Optional[str], 304 | type: Optional[str] = None, # Use this to assign device to a profile? 305 | label: Optional[str] = None, 306 | # device_profile_id: Id, # Can't make this work 307 | # software_id: Optional[Id], # Can't make this work -- probably done via another endpoint 308 | # firmware_id: Optional[Id], # Can't make this work -- probably done via another endpoint 309 | additional_info: Optional[dict[str, Any]] = None, 310 | customer: Optional["Customer"] = None, 311 | shared_attributes: Optional[dict[str, Any]] = None, 312 | server_attributes: Optional[dict[str, Any]] = None, 313 | ): 314 | """ Factory method. """ 315 | 316 | from .Device import Device 317 | 318 | data: dict[str, Any] = { 319 | "name": name, 320 | "label": label, 321 | "type": type, 322 | "additionalInfo": additional_info, 323 | } 324 | 325 | device_json = self.post("/api/device", data, "Error creating new device") 326 | # https://demo.thingsboard.io/swagger-ui.html#/device-controller/saveDeviceUsingPOST 327 | 328 | device = Device(tbapi=self, **device_json) 329 | 330 | if customer: 331 | device.assign_to(customer) 332 | 333 | if server_attributes is not None: 334 | device.set_server_attributes(server_attributes) 335 | 336 | if shared_attributes is not None: 337 | device.set_shared_attributes(shared_attributes) 338 | 339 | return device 340 | 341 | 342 | 343 | def get_device_by_id(self, device_id: Union["Id", str]): 344 | """ 345 | Returns an instantiated Device object device_id can be either an Id object or a guid 346 | """ 347 | from .TbModel import Id 348 | from .Device import Device 349 | 350 | if isinstance(device_id, Id): 351 | device_id = device_id.id 352 | # otherwise, assume device_id is a guid 353 | 354 | obj = self.get(f"/api/device/{device_id}", f"Could not retrieve Device with id '{device_id}'") 355 | 356 | # This hack is to fix a bug in TB 3.2 (and probably earlier) where customer_id comes back with NULL_GUID 357 | if obj["customerId"]["id"] == TbApi.NULL_GUID: 358 | device = self.get_device_by_name(obj["name"]) 359 | assert device 360 | return device 361 | 362 | return Device(self, **obj) 363 | 364 | 365 | def get_devices_by_name(self, device_name_prefix: str): 366 | """ 367 | Returns a list of all devices starting with the specified name 368 | """ 369 | from .Device import Device 370 | 371 | data = self.get_paged(f"/api/tenant/devices?textSearch={device_name_prefix}", f"Error fetching devices with name matching '{device_name_prefix}'") 372 | return self.tb_objects_from_list(data, Device) 373 | 374 | 375 | def get_device_by_name(self, device_name: str | None): 376 | """ Returns a device with the specified name, or None if we can't find one """ 377 | if device_name is None: # Occasionally helpful 378 | return None 379 | devices = self.get_devices_by_name(device_name) 380 | return _exact_match(device_name, devices) 381 | 382 | 383 | def get_devices_by_type(self, device_type: str): 384 | from .Device import Device 385 | 386 | data = self.get(f"/api/tenant/devices?pageSize=99999&page=0&type={device_type}", f"Error fetching devices with type '{device_type}'")["data"] 387 | return self.tb_objects_from_list(data, Device) 388 | 389 | 390 | def get_all_devices(self): 391 | from .Device import Device 392 | 393 | all_results = self.get_paged("/api/tenant/devices", "Error fetching list of all Devices") 394 | return self.tb_objects_from_list(all_results, Device) 395 | 396 | 397 | def get_all_device_profiles(self): 398 | from .Device import DeviceProfile 399 | 400 | all_results = self.get_paged("/api/deviceProfiles", "Error fetching list of all DeviceProfiles") 401 | return self.tb_objects_from_list(all_results, DeviceProfile) 402 | 403 | 404 | def get_device_profile_by_id(self, device_profile_id: Union["Id", str]): 405 | """ 406 | Returns an instantiated DeviceProfile object 407 | device_profile_id can be either an Id object or a guid 408 | """ 409 | from .DeviceProfile import DeviceProfile 410 | from .TbModel import Id 411 | 412 | if isinstance(device_profile_id, Id): 413 | device_profile_id = device_profile_id.id 414 | # otherwise, assume device_profile_id is a guid 415 | 416 | obj = self.get(f"/api/deviceProfile/{device_profile_id}", f"Could not retrieve DeviceProfile with id '{device_profile_id}'") 417 | 418 | return DeviceProfile(tbapi=self, **obj) 419 | 420 | 421 | def get_device_profiles_by_name(self, device_profile_name_prefix: str): 422 | """ Returns a list of all DeviceProfiles starting with the specified name """ 423 | from .DeviceProfile import DeviceProfile 424 | 425 | data = self.get_paged(f"/api/deviceProfiles?textSearch={device_profile_name_prefix}", f"Error fetching DeviceProfiles with name matching '{device_profile_name_prefix}'") 426 | return self.tb_objects_from_list(data, DeviceProfile) 427 | 428 | 429 | def get_device_profile_by_name(self, device_profile_name: str): 430 | """ Returns a DeviceProfile with the specified name, or None if we can't find one """ 431 | device_profiles = self.get_device_profiles_by_name(device_profile_name) 432 | return _exact_match(device_profile_name, device_profiles) 433 | 434 | 435 | def get_all_device_profile_infos(self): 436 | from .DeviceProfile import DeviceProfileInfo 437 | 438 | all_results = self.get_paged("/api/deviceProfileInfos", "Error fetching list of all DeviceProfileInfos") 439 | return self.tb_objects_from_list(all_results, DeviceProfileInfo) 440 | 441 | 442 | def get_device_profile_info_by_id(self, device_profile_info_id: Union["Id", str]): 443 | """ 444 | Returns an instantiated DeviceProfileInfo object 445 | device_profile_info_id can be either an Id object or a guid 446 | """ 447 | from .DeviceProfile import DeviceProfileInfo 448 | from .TbModel import Id 449 | 450 | if isinstance(device_profile_info_id, Id): 451 | device_profile_info_id = device_profile_info_id.id 452 | # otherwise, assume device_profile_info_id is a guid 453 | 454 | obj = self.get(f"/api/deviceProfileInfo/{device_profile_info_id}", f"Could not retrieve DeviceProfileInfo with id '{device_profile_info_id}'") 455 | 456 | return DeviceProfileInfo(tbapi=self, **obj) 457 | 458 | 459 | def get_device_profile_infos_by_name(self, device_profile_info_name_prefix: str): 460 | """ 461 | Returns a list of all DeviceProfileInfos starting with the specified name 462 | """ 463 | from .DeviceProfile import DeviceProfileInfo 464 | 465 | data = self.get_paged(f"/api/deviceProfileInfos?textSearch={device_profile_info_name_prefix}", f"Error fetching DeviceProfileInfos with name matching '{device_profile_info_name_prefix}'") 466 | return self.tb_objects_from_list(data, DeviceProfileInfo) 467 | 468 | 469 | def get_device_profile_info_by_name(self, device_profile_info_name: str): 470 | """ Returns a DeviceProfileInfo with the specified name, or None if we can't find one """ 471 | device_profile_infos = self.get_device_profile_infos_by_name(device_profile_info_name) 472 | return _exact_match(device_profile_info_name, device_profile_infos) 473 | 474 | 475 | # # TODO: create Asset object 476 | # def add_asset(self, asset_name: str, asset_type: str, shared_attributes: dict[str, Any] | None, server_attributes: dict[str, Any] | None): 477 | # data = { 478 | # "name": asset_name, 479 | # "type": asset_type 480 | # } 481 | # asset = self.post("/api/asset", data, "Error adding asset") 482 | 483 | # if server_attributes is not None: 484 | # asset.set_server_attributes(server_attributes) 485 | 486 | # if shared_attributes is not None: 487 | # asset.set_shared_attributes(shared_attributes) 488 | 489 | # return asset 490 | 491 | 492 | def get_asset_types(self): 493 | return self.get("/api/asset/types", "Error fetching list of all asset types") 494 | 495 | 496 | def get_current_user(self): 497 | """ Gets info about the user whose credentials are running this API. """ 498 | from .User import User 499 | 500 | obj: dict[str, Any] = self.get_paged("/api/users", "Error fetching info about current user")[0] 501 | return User.model_validate(obj | {"tbapi": self}) 502 | 503 | 504 | def get_current_tenant_id(self): 505 | return self.get_current_user().tenant_id 506 | 507 | 508 | def tb_objects_from_list(self, json_list: list[dict[str, Any]], object_type: Type[T]) -> list[T]: 509 | """ Given a list of json strings and a type, return a list of rehydrated objects of that type. """ 510 | objects: list[T] = [] 511 | for jsn in json_list: 512 | objects.append(object_type.model_validate(jsn | {"tbapi": self})) 513 | return objects 514 | 515 | 516 | # based off https://stackoverflow.com/questions/20658572/python-requests-print-entire-http-request-raw 517 | @staticmethod 518 | def pretty_print_request(request: Union[requests.PreparedRequest, requests.Request]): 519 | request = request.prepare() if isinstance(request, requests.Request) else request 520 | 521 | if request.headers: 522 | headers = "\n".join(f"{k}: {v}" for k, v in request.headers.items()) 523 | else: 524 | headers = "" 525 | 526 | if request.body: 527 | body = request.body.decode() if isinstance(request.body, bytes) else request.body 528 | else: 529 | body = "" 530 | 531 | print(f"{request.method} {request.path_url}\nHeaders:\n{headers}\nBody:\n{body}") 532 | 533 | 534 | def add_auth_header(self, headers: dict[str, str]): 535 | """ Modifies headers """ 536 | headers["X-Authorization"] = "Bearer " + self.get_token() 537 | 538 | 539 | def get_paged(self, params: str, msg: str) -> list[dict[str, Any]]: 540 | """ Make requests to get data that might span multiple pages. Mostly intended for internal use. """ 541 | page_size = 100 542 | all_data: list[dict[str, Any]] = [] 543 | page = 0 544 | 545 | if "?" in params: 546 | joiner = "&" 547 | else: 548 | joiner = "?" 549 | 550 | while True: 551 | resp = self.get(f"{params}{joiner}page={page}&pageSize={page_size}", msg) 552 | data = resp["data"] 553 | all_data += data 554 | 555 | if not resp["hasNext"]: 556 | break 557 | 558 | page += 1 559 | 560 | return all_data 561 | 562 | 563 | def get(self, params: str, msg: str) -> Any: # list[dict[str, Any]] ?? 564 | if self.mothership_url is None: # type: ignore 565 | raise ConfigurationError("Cannot retrieve data without a URL: create a file called config.py and define 'mothership_url' to point to your Thingsboard server.\nExample: mothership_url = 'http://www.thingsboard.org:8080'") 566 | url = self.mothership_url + params 567 | headers = {"Accept": "application/json"} 568 | self.add_auth_header(headers) 569 | 570 | if self.verbose: 571 | req = requests.Request("GET", url, headers=headers) 572 | prepared = req.prepare() 573 | TbApi.pretty_print_request(prepared) 574 | 575 | response = requests.get(url, headers=headers) 576 | self.validate_response(response, msg) 577 | 578 | return response.json() 579 | 580 | 581 | def delete(self, params: str, msg: str) -> bool: 582 | url = self.mothership_url + params 583 | headers = {"Accept": "application/json"} 584 | self.add_auth_header(headers) 585 | 586 | if self.verbose: 587 | req = requests.Request("DELETE", url, headers=headers) 588 | prepared = req.prepare() 589 | TbApi.pretty_print_request(prepared) 590 | 591 | response = requests.delete(url, headers=headers) 592 | 593 | # Don't fail if not found 594 | if response.status_code == HTTPStatus.NOT_FOUND: 595 | return False 596 | 597 | self.validate_response(response, msg) 598 | 599 | return True 600 | 601 | 602 | def post(self, params: str, data: Optional[Union[str, dict[str, Any]]], msg: str) -> dict[str, Any]: 603 | """ Data can be a string or a dict """ 604 | url = self.mothership_url + params 605 | headers = {"Accept": "application/json", "Content-Type": "application/json"} 606 | self.add_auth_header(headers) 607 | 608 | if self.verbose: 609 | req = requests.Request("POST", url, json=data, headers=headers) 610 | TbApi.pretty_print_request(req) 611 | 612 | if isinstance(data, str): 613 | data = Json.loads(data) 614 | 615 | resp = requests.post(url, json=data, headers=headers) 616 | self.validate_response(resp, msg) 617 | 618 | if not resp.text: 619 | return {} 620 | 621 | return resp.json() 622 | 623 | 624 | @staticmethod 625 | def validate_response(resp: requests.Response, msg: str) -> None: 626 | try: 627 | resp.raise_for_status() 628 | except requests.RequestException as ex: 629 | ex.args += (msg, f"RESPONSE BODY: {resp.content.decode('utf8')}") # Append response to the exception to make it easier to diagnose 630 | raise 631 | 632 | 633 | class ConfigurationError(Exception): 634 | pass 635 | 636 | 637 | class TokenError(Exception): 638 | pass 639 | 640 | 641 | U = TypeVar("U", "Customer", "Device", "DeviceProfile", "DeviceProfileInfo") 642 | 643 | def _exact_match(name: str, object_list: list[U]) -> Optional[U]: 644 | matches: list[U] = [] 645 | for obj in object_list: 646 | if obj.name == name: 647 | matches.append(obj) 648 | 649 | if not matches: 650 | return None 651 | 652 | # Check that all matches are equivalent 653 | for obj in matches: 654 | if obj != matches[0]: 655 | raise Exception(f"multiple matches were found for name {name}") 656 | 657 | return matches[0] 658 | -------------------------------------------------------------------------------- /thingsboard_api_tools/TbModel.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | from typing import Dict, Any 19 | from datetime import datetime 20 | from enum import Enum 21 | from pydantic import BaseModel, Field 22 | import pytz 23 | from .TbApi import TbApi 24 | 25 | 26 | TIMEZONE = pytz.timezone("US/Pacific") 27 | 28 | 29 | class TbModel(BaseModel): 30 | """ 31 | Class from which all TbApi classes are descended. BaseModel is a pydantic class that handles 32 | most of our dirty work such as encoding, decoding, and validating json streams. 33 | """ 34 | class Config: 35 | arbitrary_types_allowed = True # TODO: Can we get rid of this somehow? 36 | # json_encoders: Dict[Any, Callable[[Any], Any]] = { # TODO: json_encoders is deprecated 37 | # datetime: lambda v: int(v.timestamp() * 1000), # TB expresses datetimes as epochs in milliseonds 38 | # } 39 | 40 | def __repr__(self) -> str: 41 | return self.__str__() 42 | 43 | 44 | class Id(TbModel): 45 | """ Basic ID class. """ 46 | id: str 47 | entity_type: str = Field(alias="entityType") 48 | 49 | 50 | def __eq__(self, other: Any) -> bool: 51 | # from .Customer import CustomerId 52 | 53 | # if isinstance(other, CustomerId): 54 | 55 | # Check if it's a CustomerId (duck typing to avoid circular import) 56 | if hasattr(other, 'id') and hasattr(other, 'public'): 57 | return self.id == other.id.id 58 | 59 | if isinstance(other, str): 60 | return self.id == other 61 | 62 | if isinstance(other, Id): 63 | return self.id == other.id 64 | 65 | # Not sure what we were passed... but it's probably not an id 66 | return False 67 | 68 | 69 | def __str__(self) -> str: 70 | return f"Id ({self.entity_type}, {self.id})" 71 | 72 | 73 | class TbObject(TbModel): 74 | id: Id 75 | created_time: datetime | None = Field(default=None, alias="createdTime", exclude=True) # Read-only attribute 76 | 77 | tbapi: "TbApi" = Field(exclude=True) # exclude=True --> don't serialize this field 78 | 79 | 80 | def __str__(self) -> str: 81 | name: str = "" 82 | if hasattr(self, "name"): 83 | name = self.name # type: ignore 84 | 85 | return f"{self.__class__.__name__} ({name + ', ' if name else ''}id={self.id.id})" 86 | 87 | 88 | class Attribute(BaseModel): 89 | key: str 90 | value: Any 91 | last_updated: datetime = Field(alias="lastUpdateTs") 92 | 93 | 94 | class Attributes(Dict[str, Attribute]): 95 | """ 96 | A type of dict specialized for holding Thingsboard attribute data. 97 | Generally, users won't create these themselves, but will get them from methods 98 | that return them. 99 | """ 100 | 101 | class Scope(Enum): 102 | SERVER = "SERVER_SCOPE" 103 | SHARED = "SHARED_SCOPE" 104 | CLIENT = "CLIENT_SCOPE" 105 | 106 | 107 | """ Represents a set of attributes about an object. """ 108 | def __init__(self, attribute_data: list[dict[str, Any]], scope: Scope): 109 | super().__init__() 110 | 111 | for data in attribute_data: 112 | self[data["key"]] = Attribute.model_validate(data) 113 | 114 | self.scope = scope 115 | 116 | 117 | def as_dict(self) -> dict[str, Any]: 118 | """ Collapse Attributes into a simple key/value dictionary. """ 119 | attr_dict: dict[str, Any] = {} 120 | 121 | for attribute in self.values(): 122 | attr_dict[attribute.key] = attribute.value 123 | return attr_dict 124 | -------------------------------------------------------------------------------- /thingsboard_api_tools/TelemetryRecord.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from datetime import datetime 3 | from pydantic import field_serializer 4 | 5 | from .TbModel import TbModel 6 | 7 | 8 | class TelemetryRecord(TbModel): 9 | 10 | values: dict[str, Any] 11 | ts: datetime | None 12 | 13 | @field_serializer("ts") 14 | def format_ts(self, ts: datetime): 15 | return int(ts.timestamp() * 1000) 16 | 17 | 18 | def __str__(self): 19 | max_len = 20 20 | 21 | v = str(self.values) 22 | val_str = v[:max_len] 23 | if len(v) > max_len: 24 | val_str += "..." 25 | 26 | if self.ts is None: 27 | return "" # Warning: Not sure what to do here because I'm not sure when this would happen. 28 | 29 | return f"ts: {int(self.ts.timestamp() * 1000)}, values: {val_str}" 30 | 31 | 32 | def __repr__(self): 33 | return self.__str__() 34 | -------------------------------------------------------------------------------- /thingsboard_api_tools/Tenant.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | 19 | from typing import Optional, Dict, Any 20 | from pydantic import Field 21 | 22 | try: 23 | from .HasAttributes import HasAttributes 24 | from .TbModel import Id, TbObject 25 | except (ModuleNotFoundError, ImportError): 26 | from HasAttributes import HasAttributes 27 | from TbModel import Id, TbObject 28 | 29 | 30 | class Tenant(TbObject, HasAttributes): 31 | name: Optional[str] = Field(alias="title") 32 | tenant_profile_id: Id = Field(alias="tenantProfileId") 33 | region: Optional[str] 34 | address: Optional[str] 35 | address2: Optional[str] 36 | city: Optional[str] 37 | state: Optional[str] 38 | zip: Optional[str] 39 | country: Optional[str] 40 | email: Optional[str] 41 | phone: Optional[str] 42 | additional_info: Optional[Dict[str, Any]] = Field(alias="additionalInfo") 43 | 44 | 45 | def __eq__(self, other: object) -> bool: 46 | if not isinstance(other, Tenant): # Probably superfluous -- ids are guids so won't collide 47 | return False 48 | 49 | return self.id == other.id 50 | 51 | 52 | def delete(self) -> bool: 53 | """ 54 | Deletes the tenant from the server, returns True if tenant was deleted, False if it did not exist 55 | """ 56 | return self.tbapi.delete(f"/api/tenant/{self.id.id}", f"Error deleting tenant '{self.id.id}'") 57 | 58 | 59 | def update(self): 60 | return self.tbapi.post("/api/tenant", self.model_dump_json(by_alias=True), "Error updating tenant") 61 | 62 | 63 | def one_line_address(self) -> str: 64 | """ Reutrn a 1-line formatted address. """ 65 | return f"{self.address}, {((self.address2 + ', ') if self.address2 else '')} {self.city}, {self.state}" 66 | -------------------------------------------------------------------------------- /thingsboard_api_tools/User.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018-2024, Chris Eykamp 2 | 3 | # MIT License 4 | 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | # persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | # Software. 12 | 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | from typing import Any 19 | from pydantic import Field 20 | 21 | from .HasAttributes import HasAttributes 22 | from .TbModel import Id, TbObject 23 | 24 | 25 | # Intended as a read-only model -- there's no reason to create these 26 | class User(TbObject, HasAttributes): 27 | name: str | None = None 28 | tenant_id: Id = Field(alias="tenantId") 29 | customer_id: Id = Field(alias="customerId") 30 | authority: str # Permssions; e.g. "SYS_ADMIN, TENANT_ADMIN or CUSTOMER_USER" 31 | email: str | None 32 | first_name: str | None = Field(alias="firstName") 33 | last_name: str | None = Field(alias="lastName") 34 | phone: str | None 35 | additional_info: dict[str, Any] | None = Field(alias="additionalInfo") 36 | 37 | 38 | def __str__(self) -> str: 39 | return "User (" + str(self.nice_name) + ", " + str(self.id.id) + ")" 40 | 41 | 42 | def __eq__(self, other: object) -> bool: 43 | if not isinstance(other, User): 44 | return False 45 | 46 | return self.id == other.id 47 | 48 | 49 | @property 50 | def nice_name(self) -> str: 51 | """ Returns a nice name for the user. """ 52 | if self.name: 53 | return self.name 54 | if self.first_name or self.last_name: 55 | return ((self.first_name or "") + " " + (self.last_name or "")).strip() 56 | if self.email: 57 | return self.email 58 | return "No Name" 59 | -------------------------------------------------------------------------------- /thingsboard_api_tools/__init__.py: -------------------------------------------------------------------------------- 1 | # TB bugs to report: 2 | # Retrieving device by id brings back null client 3 | 4 | # Copyright 2018-2024, Chris Eykamp 5 | 6 | # MIT License 7 | 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 9 | # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 10 | # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 11 | # persons to whom the Software is furnished to do so, subject to the following conditions: 12 | 13 | # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 14 | # Software. 15 | 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 17 | # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | 21 | from .TbApi import TbApi 22 | from .TbModel import TbObject, TbModel, Id, Attributes 23 | from .Customer import Customer, CustomerId 24 | from .Dashboard import DashboardHeader, Dashboard 25 | from .Device import Device, AggregationType 26 | from .DeviceProfile import DeviceProfile, DeviceProfileInfo 27 | from .TelemetryRecord import TelemetryRecord 28 | from .EntityType import EntityType 29 | 30 | 31 | __all__ = [ 32 | "AggregationType", 33 | "Attributes", 34 | "Customer", 35 | "CustomerId", 36 | "Dashboard", 37 | "DashboardHeader", 38 | "Device", 39 | "DeviceProfile", 40 | "DeviceProfileInfo", 41 | "EntityType", 42 | "Id", 43 | "TbApi", 44 | "TbModel", 45 | "TbObject", 46 | "TelemetryRecord", 47 | ] 48 | -------------------------------------------------------------------------------- /thingsboard_api_tools/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eykamp/thingsboard_api_tools/373076800f7df1cc0cc2ca8aa87efec45b05a009/thingsboard_api_tools/py.typed -------------------------------------------------------------------------------- /thingsboard_api_tools/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "thingsboard_api_tools" 3 | version = "0.5" 4 | 5 | [tool.pytest.ini_options] 6 | # pythonpath = "src" 7 | pythonpath = ["."] 8 | 9 | addopts = [ 10 | "--import-mode=importlib", 11 | ] 12 | -------------------------------------------------------------------------------- /thingsboard_api_tools/requirements.txt: -------------------------------------------------------------------------------- 1 | faker 2 | pydantic 3 | requests 4 | pytest 5 | pytz 6 | setuptools --------------------------------------------------------------------------------