twist-innovation-api 0.0.3__tar.gz → 0.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {twist_innovation_api-0.0.3/twist_innovation_api.egg-info → twist_innovation_api-0.0.6}/PKG-INFO +2 -2
  2. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/setup.py +3 -3
  3. twist_innovation_api-0.0.6/twist/BackendAdapter.py +267 -0
  4. twist_innovation_api-0.0.6/twist/TwistAPI.py +165 -0
  5. twist_innovation_api-0.0.3/twist/TwistCbShutter.py → twist_innovation_api-0.0.6/twist/TwistBinarySensor.py +18 -3
  6. twist_innovation_api-0.0.6/twist/TwistButton.py +60 -0
  7. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist/TwistDevice.py +3 -12
  8. twist_innovation_api-0.0.6/twist/TwistGarage.py +65 -0
  9. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist/TwistModel.py +15 -0
  10. twist_innovation_api-0.0.6/twist/TwistRelay.py +68 -0
  11. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist/TwistRgb.py +32 -12
  12. twist_innovation_api-0.0.3/twist/TwistLouvre.py → twist_innovation_api-0.0.6/twist/TwistShutter.py +8 -8
  13. twist_innovation_api-0.0.6/twist/TwistTypes.py +23 -0
  14. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6/twist_innovation_api.egg-info}/PKG-INFO +2 -2
  15. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist_innovation_api.egg-info/SOURCES.txt +6 -3
  16. twist_innovation_api-0.0.3/twist/TwistAPI.py +0 -99
  17. twist_innovation_api-0.0.3/twist/TwistTypes.py +0 -72
  18. twist_innovation_api-0.0.3/twist/Variants.py +0 -29
  19. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/LICENSE +0 -0
  20. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/README.md +0 -0
  21. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/pyproject.toml +0 -0
  22. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/setup.cfg +0 -0
  23. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist/TwistLight.py +0 -0
  24. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist/TwistSensor.py +0 -0
  25. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist/__init__.py +0 -0
  26. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist_innovation_api.egg-info/dependency_links.txt +0 -0
  27. {twist_innovation_api-0.0.3 → twist_innovation_api-0.0.6}/twist_innovation_api.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
- Name: twist-innovation-api
3
- Version: 0.0.3
2
+ Name: twist_innovation_api
3
+ Version: 0.0.6
4
4
  Summary: Python library to talk to the twist-innovation api
5
5
  Home-page: https://github.com/twist-innovation/twist-innovation-api
6
6
  Author: Sibrecht Goudsmedt
@@ -1,8 +1,8 @@
1
1
  from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
- name="twist-innovation-api",
5
- version="0.0.3",
4
+ name="twist_innovation_api",
5
+ version="0.0.6",
6
6
  packages=find_packages(),
7
7
  install_requires=[
8
8
  # "requests" # For REST API
@@ -19,4 +19,4 @@ setup(
19
19
  "Operating System :: OS Independent",
20
20
  ],
21
21
  python_requires=">=3.7",
22
- )
22
+ )
@@ -0,0 +1,267 @@
1
+ # Copyright (C) 2025 Twist Innovation
2
+ # This program is free software: you can redistribute it and/or modify
3
+ # it under the terms of the GNU General Public License as published by
4
+ # the Free Software Foundation, either version 3 of the License, or
5
+ # (at your option) any later version.
6
+ #
7
+ # This program is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ #
11
+ # See the GNU General Public License for more details:
12
+ # https://www.gnu.org/licenses/gpl-3.0.html
13
+
14
+ """
15
+ Backend Adapter for Twist Device Discovery
16
+
17
+ This module handles communication with the backend server to fetch device information.
18
+ It's designed to be easily replaceable if the server API changes.
19
+
20
+ The adapter maps backend device types to Twist model classes dynamically.
21
+ """
22
+
23
+ import aiohttp
24
+ from typing import Optional, Type, Dict
25
+ from .TwistModel import TwistModel
26
+ from .TwistLight import TwistLight
27
+ from .TwistShutter import TwistShutter
28
+ from .TwistRgb import TwistRgb
29
+ from .TwistSensor import TwistSensor
30
+ from .TwistGarage import TwistGarage
31
+ from .TwistRelay import TwistRelay
32
+ from .TwistButton import TwistButton
33
+ from .TwistBinarySensor import TwistBinarySensor
34
+
35
+
36
+ class DeviceInfo:
37
+ """Container for device information from backend"""
38
+ def __init__(self, device_id: int, model_id: int, name: str, model_class: Type[TwistModel],
39
+ device_key: str, device_allocation_model_key: Optional[str] = None):
40
+ self.device_id = device_id
41
+ self.model_id = model_id
42
+ self.name = name
43
+ self.model_class = model_class
44
+ self.device_key = device_key
45
+ self.device_allocation_model_key = device_allocation_model_key
46
+
47
+
48
+ class ProductModelInfo:
49
+ """Container for product model information"""
50
+ def __init__(self, device_key: str, device_model_index: int, product_name: str,
51
+ product_model_label: str, product_model_alias: str):
52
+ self.device_key = device_key
53
+ self.device_model_index = device_model_index
54
+ self.product_name = product_name
55
+ self.product_model_label = product_model_label
56
+ self.product_model_alias = product_model_alias
57
+
58
+
59
+ class BackendAdapter:
60
+ """
61
+ Adapter for fetching device information from backend server.
62
+
63
+ If the backend API changes, only this class needs to be updated.
64
+ """
65
+
66
+ def __init__(self, base_url: str, api_key: str, installation_uuid: str):
67
+ """
68
+ Initialize backend adapter
69
+
70
+ Args:
71
+ base_url: Base URL of the backend server (e.g., "https://backbone-dev.twist-innovation.com")
72
+ api_key: API key for authentication (X-Api-Key header)
73
+ installation_uuid: Installation UUID for the API endpoint
74
+ """
75
+ self.base_url = base_url.rstrip('/')
76
+ self.api_key = api_key
77
+ self.installation_uuid = installation_uuid
78
+
79
+ async def fetch_products(self) -> dict[str, ProductModelInfo]:
80
+ """
81
+ Fetch product information from backend server
82
+
83
+ Returns:
84
+ Dictionary mapping deviceAllocationModelKey -> ProductModelInfo
85
+
86
+ Raises:
87
+ aiohttp.ClientError: If request fails
88
+ """
89
+ url = f"{self.base_url}/installations/V1/installations/{self.installation_uuid}/products"
90
+ headers = {
91
+ "accept": "text/plain",
92
+ "X-Api-Key": self.api_key
93
+ }
94
+
95
+ async with aiohttp.ClientSession() as session:
96
+ async with session.get(url, headers=headers) as response:
97
+ response.raise_for_status()
98
+ data = await response.json()
99
+
100
+ return self._parse_products(data)
101
+
102
+ def _parse_products(self, data: dict) -> dict[str, ProductModelInfo]:
103
+ """
104
+ Parse products response into dictionary for easy lookup by model key
105
+
106
+ Returns:
107
+ Dictionary mapping deviceAllocationModelKey -> ProductModelInfo
108
+ """
109
+ result = {}
110
+
111
+ for product in data.get("results", []):
112
+ product_name = product.get("name", "")
113
+
114
+ for model in product.get("models", []):
115
+ model_key = model.get("key")
116
+ device_key = model.get("deviceKey")
117
+ device_model_index = model.get("deviceModelIndex")
118
+ product_model_label = model.get("productModelLabel", "")
119
+ product_model_alias = model.get("productModelAlias", "")
120
+
121
+ if model_key:
122
+ result[model_key] = ProductModelInfo(
123
+ device_key=device_key,
124
+ device_model_index=device_model_index,
125
+ product_name=product_name,
126
+ product_model_label=product_model_label,
127
+ product_model_alias=product_model_alias
128
+ )
129
+
130
+ return result
131
+
132
+ async def fetch_installation_id(self) -> int:
133
+ """
134
+ Fetch installation ID from backend server
135
+
136
+ Returns:
137
+ Installation ID
138
+
139
+ Raises:
140
+ aiohttp.ClientError: If request fails
141
+ ValueError: If response format is invalid
142
+ """
143
+ url = f"{self.base_url}/installations/V1/installations/{self.installation_uuid}"
144
+ headers = {
145
+ "accept": "text/plain",
146
+ "X-Api-Key": self.api_key
147
+ }
148
+
149
+ async with aiohttp.ClientSession() as session:
150
+ async with session.get(url, headers=headers) as response:
151
+ response.raise_for_status()
152
+ data = await response.json()
153
+
154
+ installation_id = data.get("installation", {}).get("installationId")
155
+ if installation_id is None:
156
+ raise ValueError("Missing 'installationId' in installation response")
157
+
158
+ return installation_id
159
+
160
+ async def fetch_config(self) -> list[DeviceInfo]:
161
+ """
162
+ Fetch device configuration from backend server
163
+
164
+ Returns:
165
+ List of DeviceInfo objects
166
+
167
+ Raises:
168
+ aiohttp.ClientError: If request fails
169
+ ValueError: If response format is invalid
170
+ """
171
+ url = f"{self.base_url}/installations/V1/installations/{self.installation_uuid}/devices"
172
+ headers = {
173
+ "accept": "text/plain",
174
+ "X-Api-Key": self.api_key
175
+ }
176
+
177
+ async with aiohttp.ClientSession() as session:
178
+ async with session.get(url, headers=headers) as response:
179
+ response.raise_for_status()
180
+ data = await response.json()
181
+
182
+ return self._parse_response(data)
183
+
184
+ def _parse_response(self, data: dict) -> list[DeviceInfo]:
185
+ """
186
+ Parse backend response into device info list
187
+
188
+ This method contains the mapping logic for the current API format.
189
+ Update this method if the backend response format changes.
190
+
191
+ Args:
192
+ data: JSON response from backend (new format with results array)
193
+
194
+ Returns:
195
+ List of DeviceInfo objects
196
+ """
197
+
198
+ # Parse devices from new API format
199
+ results = data.get("results", [])
200
+ device_list = []
201
+
202
+ for device in results:
203
+ try:
204
+ twist_id = device.get("twistId")
205
+ device_key = device.get("key")
206
+ models = device.get("models", [])
207
+
208
+ # Process each model in the device
209
+ for model in models:
210
+ model_index = model.get("deviceModelIndex")
211
+ model_type_name = model.get("modelTypeName", "")
212
+ label = model.get("label", f"Model {model_index}")
213
+ device_allocation_model_key = model.get("deviceAllocationModelKey")
214
+
215
+ # Map model type name directly to model class
216
+ model_class = self._map_model_type_to_class(model_type_name)
217
+
218
+ if model_class:
219
+ device_info = DeviceInfo(
220
+ device_id=twist_id,
221
+ model_id=model_index,
222
+ name=label,
223
+ model_class=model_class,
224
+ device_key=device_key,
225
+ device_allocation_model_key=device_allocation_model_key
226
+ )
227
+ device_list.append(device_info)
228
+ else:
229
+ print(f"Unknown model type '{model_type_name}' for device {twist_id}, model {model_index}")
230
+
231
+ except (KeyError, TypeError) as e:
232
+ print(f"Skipping device due to error: {e}")
233
+ continue
234
+
235
+ return device_list
236
+
237
+ def _map_model_type_to_class(self, model_type_name: str) -> Optional[Type[TwistModel]]:
238
+ """
239
+ Map the API's modelTypeName directly to Twist model class
240
+
241
+ Args:
242
+ model_type_name: Model type name from the API
243
+
244
+ Returns:
245
+ TwistModel subclass or None if unmapped
246
+ """
247
+ mapping = {
248
+ "Louvres": TwistShutter,
249
+ "Mono Light": TwistLight,
250
+ "RGB": TwistRgb,
251
+ "Temperature": TwistSensor,
252
+ "CB Shutter": TwistShutter,
253
+ "PB Shutter": TwistShutter,
254
+ "Shutter": TwistShutter,
255
+ "Garage": TwistGarage,
256
+ "Relay": TwistRelay,
257
+ "Button": TwistButton,
258
+ "Binary Sensor": TwistBinarySensor,
259
+ "Gateway": None, # Skip Gateway models
260
+ "Weather": None, # Skip Weather models
261
+ "Wind": None, # Skip Wind models
262
+ "Rain Sensor": None, # Skip Rain Sensor models
263
+ "Pergola Protection": None, # Skip logic models
264
+ "Unknown": None, # Skip unknown models
265
+ }
266
+
267
+ return mapping.get(model_type_name)
@@ -0,0 +1,165 @@
1
+ # Copyright (C) 2025 Twist Innovation
2
+ # This program is free software: you can redistribute it and/or modify
3
+ # it under the terms of the GNU General Public License as published by
4
+ # the Free Software Foundation, either version 3 of the License, or
5
+ # (at your option) any later version.
6
+ #
7
+ # This program is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ #
11
+ # See the GNU General Public License for more details:
12
+ # https://www.gnu.org/licenses/gpl-3.0.html
13
+
14
+
15
+ import asyncio
16
+ import json
17
+ import random
18
+ from typing import Optional
19
+
20
+ from .TwistDevice import TwistDevice, TwistModel
21
+ from .BackendAdapter import BackendAdapter, DeviceInfo, ProductModelInfo
22
+
23
+
24
+ class TwistAPI:
25
+ def __init__(self, backend_url: str, api_key: str, installation_uuid: str):
26
+ """
27
+ Initialize TwistAPI
28
+
29
+ Args:
30
+ backend_url: Backend server URL (e.g., "https://backbone-dev.twist-innovation.com")
31
+ api_key: API key for backend authentication
32
+ installation_uuid: Installation UUID for the backend API
33
+ """
34
+ self._ext_publish = None
35
+ self._subscribe = None
36
+
37
+ self.function_map = {
38
+ "context": self._context_msg,
39
+ "getboard": self._get_board
40
+ }
41
+
42
+ self.device_list: list[TwistDevice] = list()
43
+ self.installation_id: Optional[int] = None
44
+
45
+ # Backend adapter for fetching device info
46
+ self._backend_adapter = BackendAdapter(backend_url, api_key, installation_uuid)
47
+
48
+ async def add_mqtt(self, publisher, subscriber):
49
+ self._ext_publish = publisher
50
+ self._subscribe = subscriber
51
+
52
+ await self._subscribe(f"v2/{self.installation_id}/rx/#", self._on_message_received)
53
+
54
+ async def get_models(self):
55
+ """
56
+ Get models from backend
57
+
58
+ Returns:
59
+ List of TwistModel objects
60
+ """
61
+ return await self._get_models_from_backend()
62
+
63
+ async def _get_models_from_backend(self):
64
+ """
65
+ Fetch devices from backend and create model list
66
+
67
+ Returns:
68
+ List of TwistModel objects
69
+ """
70
+ # Fetch installation_id from API
71
+ self.installation_id = await self._backend_adapter.fetch_installation_id()
72
+
73
+ # Fetch product info for naming
74
+ products = await self._backend_adapter.fetch_products()
75
+
76
+ # Fetch device info from API
77
+ device_infos = await self._backend_adapter.fetch_config()
78
+
79
+ # Group devices by device_id
80
+ devices_dict = {}
81
+ for device_info in device_infos:
82
+ if device_info.device_id not in devices_dict:
83
+ devices_dict[device_info.device_id] = []
84
+ devices_dict[device_info.device_id].append(device_info)
85
+
86
+ # Create TwistDevice objects with models
87
+ model_list: list[TwistModel] = list()
88
+ for device_id, infos in devices_dict.items():
89
+ # Sort by model_id to ensure correct order
90
+ infos.sort(key=lambda x: x.model_id)
91
+
92
+ # Create device if it doesn't exist
93
+ device = next((d for d in self.device_list if d.twist_id == device_id), None)
94
+ if device is None:
95
+ device = TwistDevice(device_id, 0, self)
96
+ self.device_list.append(device)
97
+
98
+ # Create models for this device
99
+ for info in infos:
100
+ model = info.model_class(info.model_id, device)
101
+
102
+ # Get product info if available using deviceAllocationModelKey
103
+ product_info = None
104
+ if info.device_allocation_model_key:
105
+ product_info = products.get(info.device_allocation_model_key)
106
+
107
+ if product_info:
108
+ model.product_name = product_info.product_name
109
+ model.name = product_info.product_model_label or product_info.product_model_alias or info.name
110
+ else:
111
+ model.product_name = None
112
+ model.name = info.name
113
+
114
+ # Add model at correct position in model_list
115
+ while len(device.model_list) <= info.model_id:
116
+ device.model_list.append(None)
117
+ device.model_list[info.model_id] = model
118
+
119
+ model_list.append(model)
120
+
121
+ return model_list
122
+
123
+ async def _publish(self, twist_id, opcode, payload: dict | None = None, model_id=None):
124
+ if self._ext_publish is not None:
125
+ topic = f"v2/{self.installation_id}/tx/{twist_id}/{opcode}"
126
+ if payload is None:
127
+ payload = dict()
128
+ payload["key"] = random.randint(1, 65535)
129
+ if model_id is not None:
130
+ topic = f"{topic}/{model_id}"
131
+
132
+ await self._ext_publish(topic, json.dumps(payload))
133
+
134
+ async def getboard(self, twist_id):
135
+ await self._publish(twist_id, "getboard")
136
+
137
+ async def activate_event(self, model: TwistModel, data: json):
138
+ await self._publish(model.parent_device.twist_id, "activate_event", data, model.model_id)
139
+
140
+ def _parse_topic(self, topic):
141
+ tpc_delim = topic.split('/')
142
+
143
+ model_id = None
144
+ if len(tpc_delim) == 6:
145
+ model_id = int(tpc_delim[5])
146
+
147
+ return {
148
+ "twist_id": int(tpc_delim[3]),
149
+ "opcode": tpc_delim[4],
150
+ "model_id": model_id
151
+ }
152
+
153
+ async def _on_message_received(self, topic, payload, qos=None):
154
+ data = self._parse_topic(topic)
155
+ if any(dev.twist_id == data["twist_id"] for dev in self.device_list) or data["opcode"] == "getboard":
156
+ if data["opcode"] in self.function_map:
157
+ await self.function_map[data["opcode"]](data["twist_id"], payload, data["model_id"])
158
+
159
+ async def _context_msg(self, twist_id, payload, model_id):
160
+ device = next((d for d in self.device_list if d.twist_id == twist_id), None)
161
+ await device.context_msg(model_id, payload)
162
+
163
+ async def _get_board(self, twist_id, payload, model_id=None):
164
+ """Handle getboard MQTT message (legacy compatibility)"""
165
+ pass
@@ -12,22 +12,37 @@
12
12
  # https://www.gnu.org/licenses/gpl-3.0.html
13
13
 
14
14
  from __future__ import annotations
15
+
15
16
  from typing import TYPE_CHECKING
16
17
 
17
18
  if TYPE_CHECKING:
18
19
  from TwistDevice import TwistDevice
20
+ from .TwistTypes import ContextErrors
19
21
  from .TwistDevice import TwistModel
20
22
 
21
23
 
22
- class TwistCbShutter(TwistModel):
24
+ class TwistBinarySensor(TwistModel):
23
25
  def __init__(self, model_id: int, parent_device: TwistDevice):
24
26
  super().__init__(model_id, parent_device)
25
27
 
26
28
  async def context_msg(self, payload: str):
27
- self.parse_general_context(payload)
29
+ data = self.parse_general_context(payload)
30
+
31
+ for ctx in data["cl"]:
32
+ index, value = self._get_value_from_context(ctx)
33
+ if index < ContextErrors.MAX.value:
34
+ if ContextErrors(index) == ContextErrors.ACTUAL:
35
+ self.actual_state = value[0]
28
36
 
29
37
  if self._update_callback is not None:
30
38
  await self._update_callback(self)
31
39
 
40
+ @property
41
+ def is_active(self) -> bool:
42
+ """Check if binary sensor is active (1) or inactive (0)"""
43
+ return bool(self.actual_state)
44
+
32
45
  def print_context(self):
33
- print(f"Cb Shutter Device: {self.parent_device.twist_id}, Model: {self.model_id},")
46
+ state = "ACTIVE" if self.is_active else "INACTIVE"
47
+ print(
48
+ f"Binary Sensor Device: {self.parent_device.twist_id}, Model: {self.model_id}, State: {state}")
@@ -0,0 +1,60 @@
1
+ # Copyright (C) 2025 Twist Innovation
2
+ # This program is free software: you can redistribute it and/or modify
3
+ # it under the terms of the GNU General Public License as published by
4
+ # the Free Software Foundation, either version 3 of the License, or
5
+ # (at your option) any later version.
6
+ #
7
+ # This program is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ #
11
+ # See the GNU General Public License for more details:
12
+ # https://www.gnu.org/licenses/gpl-3.0.html
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from TwistDevice import TwistDevice
20
+ from .TwistTypes import ContextErrors
21
+ from .TwistDevice import TwistModel
22
+ from enum import Enum
23
+
24
+
25
+ class TwistButton(TwistModel):
26
+ class ButtonEvent(Enum):
27
+ PUSH = 0x00
28
+ RELEASE = 0x01
29
+ LONG_PUSH = 0x02
30
+ LONG_RELEASE = 0x03
31
+ DOUBLE_PUSH = 0x04
32
+ DOUBLE_RELEASE = 0x05
33
+ DOUBLE_LONG_PUSH = 0x06
34
+ DOUBLE_LONG_RELEASE = 0x07
35
+
36
+ def __init__(self, model_id: int, parent_device: TwistDevice):
37
+ super().__init__(model_id, parent_device)
38
+ self.last_event: TwistButton.ButtonEvent | None = None
39
+
40
+ async def context_msg(self, payload: str):
41
+ data = self.parse_general_context(payload)
42
+
43
+ for ctx in data["cl"]:
44
+ index, value = self._get_value_from_context(ctx)
45
+ if index < ContextErrors.MAX.value:
46
+ if ContextErrors(index) == ContextErrors.ACTUAL:
47
+ self.actual_state = value[0]
48
+ # Store the event type
49
+ try:
50
+ self.last_event = TwistButton.ButtonEvent(value[0])
51
+ except ValueError:
52
+ self.last_event = None
53
+
54
+ if self._update_callback is not None:
55
+ await self._update_callback(self)
56
+
57
+ def print_context(self):
58
+ event_name = self.last_event.name if self.last_event else "UNKNOWN"
59
+ print(
60
+ f"Button Device: {self.parent_device.twist_id}, Model: {self.model_id}, Last Event: {event_name}")
@@ -17,25 +17,16 @@ from typing import TYPE_CHECKING
17
17
  if TYPE_CHECKING:
18
18
  from TwistAPI import TwistAPI
19
19
 
20
- from .TwistTypes import DeviceVariant
21
20
  from .TwistModel import TwistModel
22
21
 
23
- from .Variants import model_dict
24
-
25
22
  class TwistDevice:
26
- def __init__(self, twist_id: int, hw: int, var: DeviceVariant, api: TwistAPI):
23
+ def __init__(self, twist_id: int, hw: int, api: TwistAPI):
27
24
  self.twist_id = twist_id
28
25
  self.hw = hw
29
- self.var = var
30
26
  self.api = api
31
27
 
32
28
  self.model_list: list[TwistModel] = list()
33
29
 
34
- model_id = 0
35
- if self.var in model_dict:
36
- for model in model_dict[self.var]:
37
- self.model_list.append(model(model_id, self))
38
- model_id += 1
39
-
40
30
  async def context_msg(self, model_id: int, payload: str):
41
- await self.model_list[model_id].context_msg(payload)
31
+ if model_id < len(self.model_list) and self.model_list[model_id] is not None:
32
+ await self.model_list[model_id].context_msg(payload)
@@ -0,0 +1,65 @@
1
+ # Copyright (C) 2025 Twist Innovation
2
+ # This program is free software: you can redistribute it and/or modify
3
+ # it under the terms of the GNU General Public License as published by
4
+ # the Free Software Foundation, either version 3 of the License, or
5
+ # (at your option) any later version.
6
+ #
7
+ # This program is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ #
11
+ # See the GNU General Public License for more details:
12
+ # https://www.gnu.org/licenses/gpl-3.0.html
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from TwistDevice import TwistDevice
20
+ from .TwistTypes import ContextErrors
21
+ from .TwistDevice import TwistModel
22
+ from enum import Enum
23
+
24
+
25
+ class TwistGarage(TwistModel):
26
+ class EventIndexes(Enum):
27
+ OPEN = 0x00
28
+ CLOSE = 0x01
29
+ TOGGLE = 0x02
30
+
31
+ def __init__(self, model_id: int, parent_device: TwistDevice):
32
+ super().__init__(model_id, parent_device)
33
+
34
+ async def context_msg(self, payload: str):
35
+ data = self.parse_general_context(payload)
36
+
37
+ for ctx in data["cl"]:
38
+ index, value = self._get_value_from_context(ctx)
39
+ if index < ContextErrors.MAX.value:
40
+ if ContextErrors(index) == ContextErrors.ACTUAL:
41
+ self.actual_state = value[0]
42
+
43
+ if self._update_callback is not None:
44
+ await self._update_callback(self)
45
+
46
+ async def open(self):
47
+ await self._activate_event(TwistGarage.EventIndexes.OPEN)
48
+
49
+ async def close(self):
50
+ await self._activate_event(TwistGarage.EventIndexes.CLOSE)
51
+
52
+ async def toggle(self):
53
+ await self._activate_event(TwistGarage.EventIndexes.TOGGLE)
54
+
55
+ async def _activate_event(self, index: TwistGarage.EventIndexes):
56
+ data = {
57
+ "i": index.value,
58
+ "vl": []
59
+ }
60
+
61
+ await self.parent_device.api.activate_event(self, data)
62
+
63
+ def print_context(self):
64
+ print(
65
+ f"Garage Device: {self.parent_device.twist_id}, Model: {self.model_id}, Actual: {self.actual_state}")
@@ -39,6 +39,21 @@ class TwistModel():
39
39
 
40
40
  self._update_callback: Callable[[TwistModel], Awaitable[None]] | None = None
41
41
 
42
+ @property
43
+ def friendly_name(self) -> str:
44
+ """
45
+ Convert name attribute to friendly format.
46
+ Example: "light_living_lamp_1" -> "Light living lamp 1"
47
+
48
+ Returns:
49
+ Friendly formatted name, or "Unknown" if name is not set
50
+ """
51
+ if not hasattr(self, 'name') or not self.name:
52
+ return "Unknown"
53
+
54
+ # Replace underscores with spaces and capitalize first letter
55
+ return self.name.replace('_', ' ').capitalize()
56
+
42
57
 
43
58
  def print_context(self):
44
59
  print("function is not supported")
@@ -0,0 +1,68 @@
1
+ # Copyright (C) 2025 Twist Innovation
2
+ # This program is free software: you can redistribute it and/or modify
3
+ # it under the terms of the GNU General Public License as published by
4
+ # the Free Software Foundation, either version 3 of the License, or
5
+ # (at your option) any later version.
6
+ #
7
+ # This program is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ #
11
+ # See the GNU General Public License for more details:
12
+ # https://www.gnu.org/licenses/gpl-3.0.html
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from TwistDevice import TwistDevice
20
+ from .TwistTypes import ContextErrors
21
+ from .TwistDevice import TwistModel
22
+ from enum import Enum
23
+
24
+
25
+ class TwistRelay(TwistModel):
26
+ class EventIndexes(Enum):
27
+ SET = 0x00
28
+ CLEAR = 0x01
29
+ TOGGLE = 0x03
30
+
31
+ def __init__(self, model_id: int, parent_device: TwistDevice):
32
+ super().__init__(model_id, parent_device)
33
+
34
+ async def context_msg(self, payload: str):
35
+ data = self.parse_general_context(payload)
36
+
37
+ for ctx in data["cl"]:
38
+ index, value = self._get_value_from_context(ctx)
39
+ if index < ContextErrors.MAX.value:
40
+ if ContextErrors(index) == ContextErrors.ACTUAL:
41
+ self.actual_state = value[0]
42
+ elif ContextErrors(index) == ContextErrors.REQUESTED:
43
+ self.requested_state = value[0]
44
+
45
+ if self._update_callback is not None:
46
+ await self._update_callback(self)
47
+
48
+ async def turn_on(self):
49
+ await self._activate_event(TwistRelay.EventIndexes.SET)
50
+
51
+ async def turn_off(self):
52
+ await self._activate_event(TwistRelay.EventIndexes.CLEAR)
53
+
54
+ async def toggle(self):
55
+ await self._activate_event(TwistRelay.EventIndexes.TOGGLE)
56
+
57
+ async def _activate_event(self, index: TwistRelay.EventIndexes):
58
+ data = {
59
+ "i": index.value,
60
+ "vl": []
61
+ }
62
+
63
+ await self.parent_device.api.activate_event(self, data)
64
+
65
+ def print_context(self):
66
+ state = "ON" if self.actual_state else "OFF"
67
+ print(
68
+ f"Relay Device: {self.parent_device.twist_id}, Model: {self.model_id}, State: {state}")
@@ -53,10 +53,25 @@ class TwistRgb(TwistModel):
53
53
  await self._activate_event(TwistRgb.EventIndexes.TOGGLE)
54
54
 
55
55
  async def set_value(self, value: list[int, int, int], fading_time: int | None = None):
56
+ """Set HSV values.
57
+
58
+ Args:
59
+ value: [H, S, V] where H is 0-360 degrees, S is 0-100%, V is 0-100%
60
+ fading_time: Optional transition time in milliseconds
61
+ """
62
+ # Convert from standard ranges to device range (0-65535)
63
+ # H: 0-360 -> 0-65535 (multiply by 182.04)
64
+ # S: 0-100 -> 0-65535 (multiply by 655.35)
65
+ # V: 0-100 -> 0-65535 (multiply by 655.35)
66
+ h_value = round(value[0] * 182.04)
67
+ s_value = round(value[1] * 655.35)
68
+ v_value = round(value[2] * 655.35)
69
+ converted_value = [h_value, s_value, v_value]
70
+
56
71
  if fading_time is None:
57
- await self._activate_event(TwistRgb.EventIndexes.VALUE, value)
72
+ await self._activate_event(TwistRgb.EventIndexes.VALUE, converted_value)
58
73
  else:
59
- await self._activate_event(TwistRgb.EventIndexes.VALUE_FADING, value, fading_time)
74
+ await self._activate_event(TwistRgb.EventIndexes.VALUE_FADING, converted_value, fading_time)
60
75
 
61
76
  async def _activate_event(self, index: TwistRgb.EventIndexes, value: tuple[int, int, int] | None = None,
62
77
  fading_time: int | None = None):
@@ -67,28 +82,33 @@ class TwistRgb(TwistModel):
67
82
  if value is None:
68
83
  data["vl"] = []
69
84
  elif fading_time is None:
70
- data["vl"] = [int(v / 655.35) for v in value]
85
+ data["vl"] = list(value)
71
86
  else:
72
- value = list(value)
73
- value.append(fading_time)
74
- data["vl"] = value
87
+ data["vl"] = list(value) + [fading_time]
75
88
 
76
89
  await self.parent_device.api.activate_event(self, data)
77
90
 
78
91
  async def context_msg(self, payload: str):
92
+ """Parse context message from device.
93
+
94
+ Device sends HSV in 0-65535 range, convert to standard ranges:
95
+ H: 0-65535 -> 0-360 degrees (divide by 182.04)
96
+ S: 0-65535 -> 0-100% (divide by 655.35)
97
+ V: 0-65535 -> 0-100% (divide by 655.35)
98
+ """
79
99
  data = self.parse_general_context(payload)
80
100
 
81
101
  for ctx in data["cl"]:
82
102
  index, value = self._get_value_from_context(ctx)
83
103
  if index < ContextErrors.MAX.value:
84
104
  if ContextErrors(index) == ContextErrors.ACTUAL:
85
- self.actual_h = round(value[0] / 655.35, 0)
86
- self.actual_s = round(value[1] / 655.35, 0)
87
- self.actual_v = round(value[2] / 655.35, 0)
105
+ self.actual_h = round(value[0] / 182.04, 1)
106
+ self.actual_s = round(value[1] / 655.35, 1)
107
+ self.actual_v = round(value[2] / 655.35, 1)
88
108
  elif ContextErrors(index) == ContextErrors.REQUESTED:
89
- self.requested_h = round(value[0] / 655.35, 0)
90
- self.requested_s = round(value[0] / 655.35, 0)
91
- self.requested_v = round(value[0] / 655.35, 0)
109
+ self.requested_h = round(value[0] / 182.04, 1)
110
+ self.requested_s = round(value[1] / 655.35, 1)
111
+ self.requested_v = round(value[2] / 655.35, 1)
92
112
  else:
93
113
  if index == 6:
94
114
  self.operating_time = value[0]
@@ -22,7 +22,7 @@ from .TwistDevice import TwistModel
22
22
  from enum import Enum
23
23
 
24
24
 
25
- class TwistLouvre(TwistModel):
25
+ class TwistShutter(TwistModel):
26
26
  class EventIndexes(Enum):
27
27
  OPEN = 0
28
28
  STOP = 1
@@ -55,24 +55,24 @@ class TwistLouvre(TwistModel):
55
55
  await self._update_callback(self)
56
56
 
57
57
  async def open(self):
58
- await self._activate_event(TwistLouvre.EventIndexes.OPEN)
58
+ await self._activate_event(TwistShutter.EventIndexes.OPEN)
59
59
 
60
60
  async def stop(self):
61
- await self._activate_event(TwistLouvre.EventIndexes.STOP)
61
+ await self._activate_event(TwistShutter.EventIndexes.STOP)
62
62
 
63
63
  async def close(self):
64
- await self._activate_event(TwistLouvre.EventIndexes.CLOSE)
64
+ await self._activate_event(TwistShutter.EventIndexes.CLOSE)
65
65
 
66
66
  async def toggle(self):
67
- await self._activate_event(TwistLouvre.EventIndexes.TOGGLE)
67
+ await self._activate_event(TwistShutter.EventIndexes.TOGGLE)
68
68
 
69
69
  async def set_value(self, value: int, fading_time: int | None = None):
70
70
  if fading_time is not None:
71
71
  raise NotImplementedError("Fading time can't be used in this model")
72
72
  else:
73
- await self._activate_event(TwistLouvre.EventIndexes.VALUE, int(value * 655.35))
73
+ await self._activate_event(TwistShutter.EventIndexes.VALUE, int(value * 655.35))
74
74
 
75
- async def _activate_event(self, index: TwistLouvre.EventIndexes, value: int | None = None,
75
+ async def _activate_event(self, index: TwistShutter.EventIndexes, value: int | None = None,
76
76
  fading_time: int | None = None):
77
77
  data = {
78
78
  "i": index.value
@@ -89,5 +89,5 @@ class TwistLouvre(TwistModel):
89
89
 
90
90
  def print_context(self):
91
91
  print(
92
- f"Louvre Device: {self.parent_device.twist_id}, Model: {self.model_id}, Actual: {self.actual_state}, "
92
+ f"Shutter Device: {self.parent_device.twist_id}, Model: {self.model_id}, Actual: {self.actual_state}, "
93
93
  f"Requested: {self.requested_state}, Operating: {self.operating_time}, Current: {self.current}")
@@ -0,0 +1,23 @@
1
+ # Copyright (C) 2025 Twist Innovation
2
+ # This program is free software: you can redistribute it and/or modify
3
+ # it under the terms of the GNU General Public License as published by
4
+ # the Free Software Foundation, either version 3 of the License, or
5
+ # (at your option) any later version.
6
+ #
7
+ # This program is distributed in the hope that it will be useful,
8
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ #
11
+ # See the GNU General Public License for more details:
12
+ # https://www.gnu.org/licenses/gpl-3.0.html
13
+
14
+ from enum import Enum
15
+
16
+ class ContextErrors(Enum):
17
+ ERROR = 0
18
+ WARNING = 1
19
+ INFO = 2
20
+ PRIO = 3
21
+ ACTUAL = 4
22
+ REQUESTED = 5
23
+ MAX = 6
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
- Name: twist-innovation-api
3
- Version: 0.0.3
2
+ Name: twist_innovation_api
3
+ Version: 0.0.6
4
4
  Summary: Python library to talk to the twist-innovation api
5
5
  Home-page: https://github.com/twist-innovation/twist-innovation-api
6
6
  Author: Sibrecht Goudsmedt
@@ -2,16 +2,19 @@ LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
4
  setup.py
5
+ twist/BackendAdapter.py
5
6
  twist/TwistAPI.py
6
- twist/TwistCbShutter.py
7
+ twist/TwistBinarySensor.py
8
+ twist/TwistButton.py
7
9
  twist/TwistDevice.py
10
+ twist/TwistGarage.py
8
11
  twist/TwistLight.py
9
- twist/TwistLouvre.py
10
12
  twist/TwistModel.py
13
+ twist/TwistRelay.py
11
14
  twist/TwistRgb.py
12
15
  twist/TwistSensor.py
16
+ twist/TwistShutter.py
13
17
  twist/TwistTypes.py
14
- twist/Variants.py
15
18
  twist/__init__.py
16
19
  twist_innovation_api.egg-info/PKG-INFO
17
20
  twist_innovation_api.egg-info/SOURCES.txt
@@ -1,99 +0,0 @@
1
- # Copyright (C) 2025 Twist Innovation
2
- # This program is free software: you can redistribute it and/or modify
3
- # it under the terms of the GNU General Public License as published by
4
- # the Free Software Foundation, either version 3 of the License, or
5
- # (at your option) any later version.
6
- #
7
- # This program is distributed in the hope that it will be useful,
8
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
- #
11
- # See the GNU General Public License for more details:
12
- # https://www.gnu.org/licenses/gpl-3.0.html
13
-
14
-
15
- import asyncio
16
- import json
17
- import random
18
-
19
- from .TwistDevice import TwistDevice, TwistModel
20
- from .TwistTypes import DeviceVariant
21
-
22
-
23
- class TwistAPI:
24
- def __init__(self, installation_id: int):
25
- self._ext_publish = None
26
- self._subscribe = None
27
-
28
- self.function_map = {
29
- "context": self._context_msg,
30
- "getboard": self._get_board
31
- }
32
-
33
- self.device_list: list[TwistDevice] = list()
34
-
35
- # TODO: this should come from the API
36
- self.installation_id = installation_id
37
-
38
- async def add_mqtt(self, publisher, subscriber):
39
- self._ext_publish = publisher
40
- self._subscribe = subscriber
41
-
42
- await self._subscribe(f"v2/{self.installation_id}/rx/#", self._on_message_received)
43
-
44
- async def search_models(self):
45
- await self.getboard(0xffffffff)
46
- await asyncio.sleep(3)
47
-
48
- model_list: list[TwistModel] = list()
49
- for device in self.device_list:
50
- for model in device.model_list:
51
- model_list.append(model)
52
-
53
- return model_list
54
-
55
- async def _publish(self, twist_id, opcode, payload: dict | None = None, model_id=None):
56
- if self._ext_publish is not None:
57
- topic = f"v2/{self.installation_id}/tx/{twist_id}/{opcode}"
58
- if payload is None:
59
- payload = dict()
60
- payload["key"] = random.randint(1, 65535)
61
- if model_id is not None:
62
- topic = f"{topic}/{model_id}"
63
-
64
- await self._ext_publish(topic, json.dumps(payload))
65
-
66
- async def getboard(self, twist_id):
67
- await self._publish(twist_id, "getboard")
68
-
69
- async def activate_event(self, model: TwistModel, data: json):
70
- await self._publish(model.parent_device.twist_id, "activate_event", data, model.model_id)
71
-
72
- def _parse_topic(self, topic):
73
- tpc_delim = topic.split('/')
74
-
75
- model_id = None
76
- if len(tpc_delim) == 6:
77
- model_id = int(tpc_delim[5])
78
-
79
- return {
80
- "twist_id": int(tpc_delim[3]),
81
- "opcode": tpc_delim[4],
82
- "model_id": model_id
83
- }
84
-
85
- async def _on_message_received(self, topic, payload, qos=None):
86
- data = self._parse_topic(topic)
87
- if any(dev.twist_id == data["twist_id"] for dev in self.device_list) or data["opcode"] == "getboard":
88
- if data["opcode"] in self.function_map:
89
- await self.function_map[data["opcode"]](data["twist_id"], payload, data["model_id"])
90
-
91
- async def _context_msg(self, twist_id, payload, model_id):
92
- device = next((d for d in self.device_list if d.twist_id == twist_id), None)
93
- await device.context_msg(model_id, payload)
94
-
95
- async def _get_board(self, twist_id, payload, model_id=None):
96
- data = json.loads(payload)
97
- if not any(dev.twist_id == twist_id for dev in self.device_list):
98
- self.device_list.append(TwistDevice(twist_id, data["h"], DeviceVariant(data["v"]), self))
99
- print(f"New device with id: {twist_id}")
@@ -1,72 +0,0 @@
1
- # Copyright (C) 2025 Twist Innovation
2
- # This program is free software: you can redistribute it and/or modify
3
- # it under the terms of the GNU General Public License as published by
4
- # the Free Software Foundation, either version 3 of the License, or
5
- # (at your option) any later version.
6
- #
7
- # This program is distributed in the hope that it will be useful,
8
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
- #
11
- # See the GNU General Public License for more details:
12
- # https://www.gnu.org/licenses/gpl-3.0.html
13
-
14
- from enum import Enum
15
-
16
- class DeviceVariant(Enum):
17
- NO_VARIANT = 0xFFFF
18
- NONE = 0x0000
19
- LED_4_BUTTON_4 = 0x0001
20
- LED_1_BUTTON_1 = 0x0002
21
- MONO_LIGHT_4 = 0x0003
22
- MONO_LIGHT_2 = 0x0004
23
- TEMP_1_HUM_1_PIR_1_LUX_1_VOC_1 = 0x0005
24
- MONO_LIGHT_1_BUTTON_1 = 0x0006
25
- RGB_1_TUNABLE_WHITE_1 = 0x0007
26
- BUTTON_8 = 0x0008
27
- MONO_LIGHT_2_LUX2BRIGHTNESS_1 = 0x0009
28
- RGB_1_MONO_LIGHT_2 = 0x000A
29
- LUX_1_UV_1_TEMP_1_HUM_1_WINDSPEED_1_GUSTSPEED_1_WINDDIR_1_RAINFALL_1_PRES_1 = 0x000B
30
- RGB_1 = 0x000C
31
- BUTTON_6_LUX_6 = 0x00D2 # Corrected 0x000D2 to 0x00D2
32
- BUTTON_4 = 0x000E
33
- TUNABLE_WHITE_2 = 0x000F
34
- WIND_1_LUX_1_RAIN_1 = 0x0010
35
- BUTTON_1 = 0x0011
36
- PULSE_CONTACT_2 = 0x0012
37
- TBSHUTTER_1 = 0x0013
38
- MONO_LIGHT_32 = 0x0142
39
- REPEATER_1 = 0x0015
40
- REPEATER_1_LED_1 = 0x0016
41
- LED_12 = 0x0172
42
- BUTTON_12 = 0x0182
43
- CBSHUTTER_1_VOLTAGE_1_CURRENT_1 = 0x0019
44
- TBSHUTTER_6 = 0x001A
45
- CBSHUTTER_1_MONO_LIGHT_1 = 0x001B
46
- CBSHUTTER_1 = 0x001C
47
- MONO_LIGHT_1 = 0x001D
48
- HEATER_1 = 0x001E
49
- PBSHUTTER_1 = 0x001F
50
- CBSHUTTER_2_MONO_LIGHT_4_TEMPERATURE_1 = 0x0020
51
- GATEWAY_1_WEATHER_1_TEMPERATURE_1_WIND_1_RAIN_1 = 0x0021
52
- CBSHUTTER_3 = 0x0022
53
- BUTTON_10 = 0x0023
54
- MATRIX_1 = 0x0024
55
- RAIN_1 = 0x0025
56
- RAIN_1_TEMPERATURE_1_WIND_1_WEATHER_1 = 0x0026
57
- LOUVRE_2_MONO_LIGHT_4_TEMPERATURE_1 = 0x0027
58
- LOUVRE_2_MONO_LIGHT_4 = 0x0028
59
-
60
-
61
- class Models(Enum):
62
- LOUVRE = 0
63
- MONO_LIGHT = 1
64
-
65
- class ContextErrors(Enum):
66
- ERROR = 0
67
- WARNING = 1
68
- INFO = 2
69
- PRIO = 3
70
- ACTUAL = 4
71
- REQUESTED = 5
72
- MAX = 6
@@ -1,29 +0,0 @@
1
- # Copyright (C) 2025 Twist Innovation
2
- # This program is free software: you can redistribute it and/or modify
3
- # it under the terms of the GNU General Public License as published by
4
- # the Free Software Foundation, either version 3 of the License, or
5
- # (at your option) any later version.
6
- #
7
- # This program is distributed in the hope that it will be useful,
8
- # but WITHOUT ANY WARRANTY; without even the implied warranty of
9
- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
- #
11
- # See the GNU General Public License for more details:
12
- # https://www.gnu.org/licenses/gpl-3.0.html
13
-
14
- from .TwistLight import TwistLight
15
- from .TwistLouvre import TwistLouvre
16
- from .TwistRgb import TwistRgb
17
- from .TwistSensor import TwistSensor
18
- from .TwistCbShutter import TwistCbShutter
19
- from .TwistTypes import DeviceVariant
20
-
21
- model_dict = {
22
- DeviceVariant.LOUVRE_2_MONO_LIGHT_4: [TwistLouvre, TwistLouvre, TwistLight, TwistLight, TwistLight,
23
- TwistLight],
24
- DeviceVariant.RGB_1: [TwistRgb],
25
- DeviceVariant.MONO_LIGHT_4: [TwistLight, TwistLight, TwistLight, TwistLight],
26
- DeviceVariant.GATEWAY_1_WEATHER_1_TEMPERATURE_1_WIND_1_RAIN_1: [TwistSensor, TwistSensor, TwistSensor,
27
- TwistSensor, TwistSensor],
28
- DeviceVariant.CBSHUTTER_1_MONO_LIGHT_1: [TwistCbShutter, TwistLight],
29
- }