twist-innovation-api 0.0.2__py3-none-any.whl → 0.0.4__py3-none-any.whl

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.
@@ -0,0 +1,295 @@
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 .TwistLouvre import TwistLouvre
28
+ from .TwistRgb import TwistRgb
29
+ from .TwistSensor import TwistSensor
30
+ from .TwistCbShutter import TwistCbShutter
31
+ from .TwistGarage import TwistGarage
32
+ from .TwistRelay import TwistRelay
33
+ from .TwistButton import TwistButton
34
+ from .TwistBinarySensor import TwistBinarySensor
35
+
36
+
37
+ # Type mapping: maps backend type strings to Twist model classes
38
+ # Add new mappings here when new device types are added
39
+ TYPE_TO_MODEL_MAP: Dict[str, Type[TwistModel]] = {
40
+ "Mono_Light": TwistLight,
41
+ "TB_Shutter": TwistLouvre,
42
+ "Louvre": TwistLouvre,
43
+ "RGB": TwistRgb,
44
+ "Sensor": TwistSensor,
45
+ "CB_Shutter": TwistCbShutter,
46
+ "PB_Shutter": TwistCbShutter,
47
+ "Garage": TwistGarage,
48
+ "Relay": TwistRelay,
49
+ "Button": TwistButton,
50
+ "Binary_Sensor": TwistBinarySensor,
51
+ }
52
+
53
+
54
+ class DeviceInfo:
55
+ """Container for device information from backend"""
56
+ def __init__(self, device_id: int, model_id: int, name: str, device_type: str,
57
+ device_key: str, device_allocation_model_key: Optional[str] = None):
58
+ self.device_id = device_id
59
+ self.model_id = model_id
60
+ self.name = name
61
+ self.device_type = device_type
62
+ self.device_key = device_key
63
+ self.device_allocation_model_key = device_allocation_model_key
64
+
65
+
66
+ class ProductModelInfo:
67
+ """Container for product model information"""
68
+ def __init__(self, device_key: str, device_model_index: int, product_name: str,
69
+ product_model_label: str, product_model_alias: str):
70
+ self.device_key = device_key
71
+ self.device_model_index = device_model_index
72
+ self.product_name = product_name
73
+ self.product_model_label = product_model_label
74
+ self.product_model_alias = product_model_alias
75
+
76
+
77
+ class BackendAdapter:
78
+ """
79
+ Adapter for fetching device information from backend server.
80
+
81
+ If the backend API changes, only this class needs to be updated.
82
+ """
83
+
84
+ def __init__(self, base_url: str, api_key: str, installation_uuid: str):
85
+ """
86
+ Initialize backend adapter
87
+
88
+ Args:
89
+ base_url: Base URL of the backend server (e.g., "https://backbone-dev.twist-innovation.com")
90
+ api_key: API key for authentication (X-Api-Key header)
91
+ installation_uuid: Installation UUID for the API endpoint
92
+ """
93
+ self.base_url = base_url.rstrip('/')
94
+ self.api_key = api_key
95
+ self.installation_uuid = installation_uuid
96
+
97
+ async def fetch_products(self) -> dict[str, ProductModelInfo]:
98
+ """
99
+ Fetch product information from backend server
100
+
101
+ Returns:
102
+ Dictionary mapping deviceAllocationModelKey -> ProductModelInfo
103
+
104
+ Raises:
105
+ aiohttp.ClientError: If request fails
106
+ """
107
+ url = f"{self.base_url}/installations/V1/installations/{self.installation_uuid}/products"
108
+ headers = {
109
+ "accept": "text/plain",
110
+ "X-Api-Key": self.api_key
111
+ }
112
+
113
+ async with aiohttp.ClientSession() as session:
114
+ async with session.get(url, headers=headers) as response:
115
+ response.raise_for_status()
116
+ data = await response.json()
117
+
118
+ return self._parse_products(data)
119
+
120
+ def _parse_products(self, data: dict) -> dict[str, ProductModelInfo]:
121
+ """
122
+ Parse products response into dictionary for easy lookup by model key
123
+
124
+ Returns:
125
+ Dictionary mapping deviceAllocationModelKey -> ProductModelInfo
126
+ """
127
+ result = {}
128
+
129
+ for product in data.get("results", []):
130
+ product_name = product.get("name", "")
131
+
132
+ for model in product.get("models", []):
133
+ model_key = model.get("key")
134
+ device_key = model.get("deviceKey")
135
+ device_model_index = model.get("deviceModelIndex")
136
+ product_model_label = model.get("productModelLabel", "")
137
+ product_model_alias = model.get("productModelAlias", "")
138
+
139
+ if model_key:
140
+ result[model_key] = ProductModelInfo(
141
+ device_key=device_key,
142
+ device_model_index=device_model_index,
143
+ product_name=product_name,
144
+ product_model_label=product_model_label,
145
+ product_model_alias=product_model_alias
146
+ )
147
+
148
+ return result
149
+
150
+ async def fetch_installation_id(self) -> int:
151
+ """
152
+ Fetch installation ID from backend server
153
+
154
+ Returns:
155
+ Installation ID
156
+
157
+ Raises:
158
+ aiohttp.ClientError: If request fails
159
+ ValueError: If response format is invalid
160
+ """
161
+ url = f"{self.base_url}/installations/V1/installations/{self.installation_uuid}"
162
+ headers = {
163
+ "accept": "text/plain",
164
+ "X-Api-Key": self.api_key
165
+ }
166
+
167
+ async with aiohttp.ClientSession() as session:
168
+ async with session.get(url, headers=headers) as response:
169
+ response.raise_for_status()
170
+ data = await response.json()
171
+
172
+ installation_id = data.get("installation", {}).get("installationId")
173
+ if installation_id is None:
174
+ raise ValueError("Missing 'installationId' in installation response")
175
+
176
+ return installation_id
177
+
178
+ async def fetch_config(self) -> list[DeviceInfo]:
179
+ """
180
+ Fetch device configuration from backend server
181
+
182
+ Returns:
183
+ List of DeviceInfo objects
184
+
185
+ Raises:
186
+ aiohttp.ClientError: If request fails
187
+ ValueError: If response format is invalid
188
+ """
189
+ url = f"{self.base_url}/installations/V1/installations/{self.installation_uuid}/devices"
190
+ headers = {
191
+ "accept": "text/plain",
192
+ "X-Api-Key": self.api_key
193
+ }
194
+
195
+ async with aiohttp.ClientSession() as session:
196
+ async with session.get(url, headers=headers) as response:
197
+ response.raise_for_status()
198
+ data = await response.json()
199
+
200
+ return self._parse_response(data)
201
+
202
+ def _parse_response(self, data: dict) -> list[DeviceInfo]:
203
+ """
204
+ Parse backend response into device info list
205
+
206
+ This method contains the mapping logic for the current API format.
207
+ Update this method if the backend response format changes.
208
+
209
+ Args:
210
+ data: JSON response from backend (new format with results array)
211
+
212
+ Returns:
213
+ List of DeviceInfo objects
214
+ """
215
+
216
+ # Parse devices from new API format
217
+ results = data.get("results", [])
218
+ device_list = []
219
+
220
+ for device in results:
221
+ try:
222
+ twist_id = device.get("twistId")
223
+ device_key = device.get("key")
224
+ models = device.get("models", [])
225
+
226
+ # Process each model in the device
227
+ for model in models:
228
+ model_index = model.get("deviceModelIndex")
229
+ model_type_name = model.get("modelTypeName", "")
230
+ label = model.get("label", f"Model {model_index}")
231
+ device_allocation_model_key = model.get("deviceAllocationModelKey")
232
+
233
+ # Map model type name to device type string
234
+ device_type = self._map_model_type_to_device_type(model_type_name)
235
+
236
+ if device_type:
237
+ device_info = DeviceInfo(
238
+ device_id=twist_id,
239
+ model_id=model_index,
240
+ name=label,
241
+ device_type=device_type,
242
+ device_key=device_key,
243
+ device_allocation_model_key=device_allocation_model_key
244
+ )
245
+ device_list.append(device_info)
246
+ else:
247
+ print(f"Unknown model type '{model_type_name}' for device {twist_id}, model {model_index}")
248
+
249
+ except (KeyError, TypeError) as e:
250
+ print(f"Skipping device due to error: {e}")
251
+ continue
252
+
253
+ return device_list
254
+
255
+ def _map_model_type_to_device_type(self, model_type_name: str) -> Optional[str]:
256
+ """
257
+ Map the new API's modelTypeName to the device type string used internally
258
+
259
+ Args:
260
+ model_type_name: Model type name from the new API
261
+
262
+ Returns:
263
+ Device type string or None if unmapped
264
+ """
265
+ # Mapping from new API model type names to internal device type strings
266
+ mapping = {
267
+ "Louvres": "Louvre",
268
+ "Mono Light": "Mono_Light",
269
+ "RGB": "RGB",
270
+ "Temperature": "Sensor",
271
+ "CB Shutter": "CB_Shutter",
272
+ "PB Shutter": "PB_Shutter",
273
+ "Garage": "Garage",
274
+ "Relay": "Relay",
275
+ "Button": "Button",
276
+ "Binary Sensor": "Binary_Sensor",
277
+ }
278
+
279
+ return mapping.get(model_type_name)
280
+
281
+ @staticmethod
282
+ def get_model_class(device_type: str) -> Type[TwistModel]:
283
+ """
284
+ Get the appropriate model class for a device type.
285
+
286
+ This method maps backend device types to Twist model classes.
287
+ If a type is not recognized, it returns the base TwistModel class.
288
+
289
+ Args:
290
+ device_type: Device type string from backend
291
+
292
+ Returns:
293
+ Appropriate TwistModel subclass
294
+ """
295
+ return TYPE_TO_MODEL_MAP.get(device_type, TwistModel)
twist/TwistAPI.py CHANGED
@@ -15,17 +15,24 @@
15
15
  import asyncio
16
16
  import json
17
17
  import random
18
- from typing import Callable, Awaitable
18
+ from typing import Optional
19
19
 
20
20
  from .TwistDevice import TwistDevice, TwistModel
21
- from .TwistTypes import DeviceVariant
21
+ from .BackendAdapter import BackendAdapter, DeviceInfo, ProductModelInfo
22
22
 
23
23
 
24
24
  class TwistAPI:
25
- def __init__(self, installation_id: int, model_update: Callable[[TwistModel], Awaitable[None]]):
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
+ """
26
34
  self._ext_publish = None
27
35
  self._subscribe = None
28
- self._callback: Callable[[TwistModel], Awaitable[None]] = model_update
29
36
 
30
37
  self.function_map = {
31
38
  "context": self._context_msg,
@@ -33,9 +40,10 @@ class TwistAPI:
33
40
  }
34
41
 
35
42
  self.device_list: list[TwistDevice] = list()
43
+ self.installation_id: Optional[int] = None
36
44
 
37
- # TODO: this should come from the API
38
- self.installation_id = installation_id
45
+ # Backend adapter for fetching device info
46
+ self._backend_adapter = BackendAdapter(backend_url, api_key, installation_uuid)
39
47
 
40
48
  async def add_mqtt(self, publisher, subscriber):
41
49
  self._ext_publish = publisher
@@ -43,13 +51,79 @@ class TwistAPI:
43
51
 
44
52
  await self._subscribe(f"v2/{self.installation_id}/rx/#", self._on_message_received)
45
53
 
46
- async def search_models(self):
47
- await self.getboard(0xffffffff)
48
- await asyncio.sleep(3)
54
+ async def get_models(self):
55
+ """
56
+ Get models from backend
49
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
50
87
  model_list: list[TwistModel] = list()
51
- for device in self.device_list:
52
- for model in device.model_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_class = BackendAdapter.get_model_class(info.device_type)
101
+
102
+ if model_class == TwistModel:
103
+ print(f"Skipping unknown device type '{info.device_type}' for '{info.name}' (device {info.device_id}, model {info.model_id})")
104
+ continue
105
+
106
+ model = model_class(info.model_id, device)
107
+
108
+ # Get product info if available using deviceAllocationModelKey
109
+ product_info = None
110
+ if info.device_allocation_model_key:
111
+ product_info = products.get(info.device_allocation_model_key)
112
+
113
+ if product_info:
114
+ model.product_name = product_info.product_name
115
+ model.name = product_info.product_model_label or product_info.product_model_alias or info.name
116
+ else:
117
+ model.product_name = None
118
+ model.name = info.name
119
+
120
+ model.device_type = info.device_type
121
+
122
+ # Add model at correct position in model_list
123
+ while len(device.model_list) <= info.model_id:
124
+ device.model_list.append(None)
125
+ device.model_list[info.model_id] = model
126
+
53
127
  model_list.append(model)
54
128
 
55
129
  return model_list
@@ -92,12 +166,8 @@ class TwistAPI:
92
166
 
93
167
  async def _context_msg(self, twist_id, payload, model_id):
94
168
  device = next((d for d in self.device_list if d.twist_id == twist_id), None)
95
- model = await device.context_msg(model_id, payload)
96
-
97
- await self._callback(model)
169
+ await device.context_msg(model_id, payload)
98
170
 
99
171
  async def _get_board(self, twist_id, payload, model_id=None):
100
- data = json.loads(payload)
101
- if not any(dev.twist_id == twist_id for dev in self.device_list):
102
- self.device_list.append(TwistDevice(twist_id, data["h"], DeviceVariant(data["v"]), self))
103
- print(f"New device with id: {twist_id}")
172
+ """Handle getboard MQTT message (legacy compatibility)"""
173
+ pass
@@ -0,0 +1,48 @@
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
+
23
+
24
+ class TwistBinarySensor(TwistModel):
25
+ def __init__(self, model_id: int, parent_device: TwistDevice):
26
+ super().__init__(model_id, parent_device)
27
+
28
+ async def context_msg(self, payload: str):
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]
36
+
37
+ if self._update_callback is not None:
38
+ await self._update_callback(self)
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
+
45
+ def print_context(self):
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}")
twist/TwistButton.py ADDED
@@ -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}")
twist/TwistCbShutter.py CHANGED
@@ -26,5 +26,8 @@ class TwistCbShutter(TwistModel):
26
26
  async def context_msg(self, payload: str):
27
27
  self.parse_general_context(payload)
28
28
 
29
+ if self._update_callback is not None:
30
+ await self._update_callback(self)
31
+
29
32
  def print_context(self):
30
33
  print(f"Cb Shutter Device: {self.parent_device.twist_id}, Model: {self.model_id},")
twist/TwistDevice.py CHANGED
@@ -17,26 +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)
42
- return self.model_list[model_id]
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)
twist/TwistGarage.py ADDED
@@ -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}")
twist/TwistLight.py CHANGED
@@ -44,6 +44,7 @@ class TwistLight(TwistModel):
44
44
  await self._activate_event(TwistLight.EventIndexes.TOGGLE)
45
45
 
46
46
  async def set_value(self, value: int, fading_time: int | None = None):
47
+ value = value * 655.35
47
48
  if fading_time is None:
48
49
  await self._activate_event(TwistLight.EventIndexes.VALUE, value)
49
50
  else:
@@ -79,6 +80,9 @@ class TwistLight(TwistModel):
79
80
  elif index == 7:
80
81
  self.current = value[0]
81
82
 
83
+ if self._update_callback is not None:
84
+ await self._update_callback(self)
85
+
82
86
  def print_context(self):
83
87
  print(
84
88
  f"Light Device: {self.parent_device.twist_id}, Model: {self.model_id}, Actual: {self.actual_state}, "
twist/TwistLouvre.py CHANGED
@@ -12,6 +12,7 @@
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:
@@ -50,6 +51,9 @@ class TwistLouvre(TwistModel):
50
51
  elif index == 7:
51
52
  self.current = value[0]
52
53
 
54
+ if self._update_callback is not None:
55
+ await self._update_callback(self)
56
+
53
57
  async def open(self):
54
58
  await self._activate_event(TwistLouvre.EventIndexes.OPEN)
55
59
 
@@ -66,10 +70,10 @@ class TwistLouvre(TwistModel):
66
70
  if fading_time is not None:
67
71
  raise NotImplementedError("Fading time can't be used in this model")
68
72
  else:
69
- await self._activate_event(TwistLouvre.EventIndexes.VALUE, value)
73
+ await self._activate_event(TwistLouvre.EventIndexes.VALUE, int(value * 655.35))
70
74
 
71
75
  async def _activate_event(self, index: TwistLouvre.EventIndexes, value: int | None = None,
72
- fading_time: int | None = None):
76
+ fading_time: int | None = None):
73
77
  data = {
74
78
  "i": index.value
75
79
  }
twist/TwistModel.py CHANGED
@@ -12,12 +12,15 @@
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
19
20
 
20
21
  import json
22
+
23
+ from typing import Callable, Awaitable
21
24
  from .TwistTypes import ContextErrors
22
25
 
23
26
 
@@ -34,9 +37,28 @@ class TwistModel():
34
37
  "prio": None
35
38
  }
36
39
 
40
+ self._update_callback: Callable[[TwistModel], Awaitable[None]] | None = None
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
+
57
+
37
58
  def print_context(self):
38
59
  print("function is not supported")
39
60
 
61
+
40
62
  def parse_general_context(self, payload: str):
41
63
  data = json.loads(payload)
42
64
 
@@ -54,29 +76,42 @@ class TwistModel():
54
76
  self.errors["prio"] = value
55
77
  return data
56
78
 
79
+
57
80
  def _get_value_from_context(self, ctx: dict):
58
81
  return ctx["i"], ctx["vl"]
59
82
 
83
+
60
84
  async def context_msg(self, payload):
61
85
  raise NotImplementedError("Function not supported for this model")
62
86
 
87
+
63
88
  async def turn_on(self):
64
89
  raise NotImplementedError("Function not supported for this model")
65
90
 
91
+
66
92
  async def turn_off(self):
67
93
  raise NotImplementedError("Function not supported for this model")
68
94
 
95
+
69
96
  async def open(self):
70
97
  raise NotImplementedError("Function not supported for this model")
71
98
 
99
+
72
100
  async def stop(self):
73
101
  raise NotImplementedError("Function not supported for this model")
74
102
 
103
+
75
104
  async def close(self):
76
105
  raise NotImplementedError("Function not supported for this model")
77
106
 
107
+
78
108
  async def toggle(self):
79
109
  raise NotImplementedError("Function not supported for this model")
80
110
 
111
+
81
112
  async def set_value(self, value: int | list[int, int] | list[int, int, int], fading_time: int | None = None):
82
113
  raise NotImplementedError("Function not supported for this model")
114
+
115
+
116
+ def register_update_cb(self, cb: Callable[[TwistModel], Awaitable[None]]):
117
+ self._update_callback = cb
twist/TwistRelay.py ADDED
@@ -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}")
twist/TwistRgb.py CHANGED
@@ -12,6 +12,7 @@
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:
@@ -57,8 +58,8 @@ class TwistRgb(TwistModel):
57
58
  else:
58
59
  await self._activate_event(TwistRgb.EventIndexes.VALUE_FADING, value, fading_time)
59
60
 
60
- async def _activate_event(self, index: TwistRgb.EventIndexes, value: list[int, int, int] | None = None,
61
- fading_time: int | None = None):
61
+ async def _activate_event(self, index: TwistRgb.EventIndexes, value: tuple[int, int, int] | None = None,
62
+ fading_time: int | None = None):
62
63
  data = {
63
64
  "i": index.value
64
65
  }
@@ -66,8 +67,9 @@ class TwistRgb(TwistModel):
66
67
  if value is None:
67
68
  data["vl"] = []
68
69
  elif fading_time is None:
69
- data["vl"] = value
70
+ data["vl"] = [int(v / 655.35) for v in value]
70
71
  else:
72
+ value = list(value)
71
73
  value.append(fading_time)
72
74
  data["vl"] = value
73
75
 
@@ -91,6 +93,9 @@ class TwistRgb(TwistModel):
91
93
  if index == 6:
92
94
  self.operating_time = value[0]
93
95
 
96
+ if self._update_callback is not None:
97
+ await self._update_callback(self)
98
+
94
99
  def print_context(self):
95
100
  print(f"Rgb Device: {self.parent_device.twist_id}, Model: {self.model_id}, "
96
101
  f"Actual: h{self.actual_h} s{self.actual_s} v{self.actual_v}")
twist/TwistSensor.py CHANGED
@@ -27,5 +27,8 @@ class TwistSensor(TwistModel):
27
27
  async def context_msg(self, payload: str):
28
28
  self.parse_general_context(payload)
29
29
 
30
+ if self._update_callback is not None:
31
+ await self._update_callback(self)
32
+
30
33
  def print_context(self):
31
34
  print(f"Sensor Device: {self.parent_device.twist_id}, Model: {self.model_id}")
twist/TwistTypes.py CHANGED
@@ -13,55 +13,6 @@
13
13
 
14
14
  from enum import Enum
15
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
16
  class ContextErrors(Enum):
66
17
  ERROR = 0
67
18
  WARNING = 1
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: twist-innovation-api
3
- Version: 0.0.2
3
+ Version: 0.0.4
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
@@ -11,15 +11,6 @@ Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.7
12
12
  Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
- Dynamic: author
15
- Dynamic: author-email
16
- Dynamic: classifier
17
- Dynamic: description
18
- Dynamic: description-content-type
19
- Dynamic: home-page
20
- Dynamic: license-file
21
- Dynamic: requires-python
22
- Dynamic: summary
23
14
 
24
15
  # Twist Innovation API
25
16
 
@@ -133,12 +124,13 @@ async def main():
133
124
  asyncio.create_task(listen())
134
125
 
135
126
  # Initialize Twist API with async methods
136
- twist_api = TwistAPI(8, on_model_update)
127
+ twist_api = TwistAPI(8)
137
128
  await twist_api.add_mqtt(mqtt_publish, mqtt_subscribe)
138
129
 
139
130
  twist_model_list: list[TwistModel] = await twist_api.search_models()
140
131
 
141
132
  for model in twist_model_list:
133
+ model.register_update_cb(on_model_update)
142
134
  print(f"{type(model)} has Model id: {model.model_id}, Device id: {model.parent_device.twist_id}")
143
135
 
144
136
  while True:
@@ -0,0 +1,20 @@
1
+ twist/BackendAdapter.py,sha256=vvFomOgBiyKOZJ_n565AdHzdCgB-hNEU9UVDRONdhZc,10509
2
+ twist/TwistAPI.py,sha256=0wwIiJRwV92tVoLdSJ6NYnZHu-UAPAVYZ7jaGsLpQq0,6477
3
+ twist/TwistBinarySensor.py,sha256=k9gsF1dTR3E_kiB60R1SUuUc9oUGGRGs592fQqy2VPM,1747
4
+ twist/TwistButton.py,sha256=p5svcsU6maGwyWa06NnlKCGUXHoFzuwFPEQI0UqoGB4,2172
5
+ twist/TwistCbShutter.py,sha256=mWQa4ro30uI8qm0J1rkR8M0KP7IwxlCRfWt0wSyCOLA,1199
6
+ twist/TwistDevice.py,sha256=-ttj9LrZhR3BwLK4ADSe2TrTtY5Z3GMkz3BruUP_jCU,1159
7
+ twist/TwistGarage.py,sha256=RaE0Pi-1dE9FwHEIANf--Uig9tFt6EAZBt_lxjZgk18,2149
8
+ twist/TwistLight.py,sha256=6f-gPfP6v7BgaRJUFt7Q3r990y2L0812d76K_33KSTw,3230
9
+ twist/TwistLouvre.py,sha256=f2ocRjLkf3qC45hbcHdEOboA2mbkaxLaOa15tvkHOHY,3320
10
+ twist/TwistModel.py,sha256=HKDDm-vaRiNEF-a67N-cjWw2UZDiSPSRMkEmlAIUPTg,3667
11
+ twist/TwistRelay.py,sha256=VL9eKXSOoZ_9GHT8TGZKDU1hGH-cgoogoMmJWLfjOqc,2309
12
+ twist/TwistRgb.py,sha256=V7YuaqJaLTH3cL8CsQ3xIztfVKfqdMMJ7uyh9UZQDwA,3572
13
+ twist/TwistSensor.py,sha256=6eYrwtZFFFOHmoHS68JHmsyi6FVVzoQy19KooGU0vMI,1192
14
+ twist/TwistTypes.py,sha256=k1CdLqjhJi8MyrFZsHkDsEoL9gEXF-HQv6I-r3G7ngA,726
15
+ twist/__init__.py,sha256=gf8GHc9utiG0vD8GP3IfzLFHAG-ev1TXBIm0XUHxy38,201
16
+ twist_innovation_api-0.0.4.dist-info/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
17
+ twist_innovation_api-0.0.4.dist-info/METADATA,sha256=c5eSo06IcHRdW1vTHfXxQdyEYcrUtUctP98MaL8Lx8c,4139
18
+ twist_innovation_api-0.0.4.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
19
+ twist_innovation_api-0.0.4.dist-info/top_level.txt,sha256=mkoeBkRPFodjnd-3UDf9YndjTQ6Sriz-EKSI4nQ7brs,6
20
+ twist_innovation_api-0.0.4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: bdist_wheel (0.42.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
twist/Variants.py DELETED
@@ -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
- }
@@ -1,16 +0,0 @@
1
- twist/TwistAPI.py,sha256=hXkkxkUM9W9oDczRKwioDJ1SZA0s1K8Ed612YkvtUmk,3797
2
- twist/TwistCbShutter.py,sha256=Ps_Yvr7pl71wvt0h3BU12MyccUitA6qJMyBP-Yq9qks,1106
3
- twist/TwistDevice.py,sha256=1wfLNB0BgJHDSUrBYD4gv_Txak7SS4mmHrJ0jcMxfI8,1421
4
- twist/TwistLight.py,sha256=TrlZ03Aztc8-Qvybv96RYA_21zAke6GLBGHFpk-RznU,3106
5
- twist/TwistLouvre.py,sha256=wFl8MOMMslKDzSr2FkqArXXoJ5CWvBqMde7-UTlEpgU,3206
6
- twist/TwistModel.py,sha256=pyaGiw2vpaCsZkp0xC6XDB7HABGZM_2X7aZCqAxhmE0,2925
7
- twist/TwistRgb.py,sha256=MxAshwe4fczGD-pEgMAXG5847kKR0KFm7WemWKeDKHs,3412
8
- twist/TwistSensor.py,sha256=C1uHxdVvixnJeijeWAhArSAu5e7Dag7yt-dqqt407lg,1099
9
- twist/TwistTypes.py,sha256=wsp-lnkaU70wNdoKWOWcnsq89vBheBYmM31ZXW3FMRw,2166
10
- twist/Variants.py,sha256=SFAZ7bSmTRomPspOfU1qNWEW-htRuyhTKuyGKK9Qh54,1369
11
- twist/__init__.py,sha256=gf8GHc9utiG0vD8GP3IfzLFHAG-ev1TXBIm0XUHxy38,201
12
- twist_innovation_api-0.0.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
13
- twist_innovation_api-0.0.2.dist-info/METADATA,sha256=g7LK4ufC35S-4xD1w8me6pb95jbmURlhALaRs7p3yp0,4298
14
- twist_innovation_api-0.0.2.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
15
- twist_innovation_api-0.0.2.dist-info/top_level.txt,sha256=mkoeBkRPFodjnd-3UDf9YndjTQ6Sriz-EKSI4nQ7brs,6
16
- twist_innovation_api-0.0.2.dist-info/RECORD,,