homebridge-kasa-python 2.6.13 → 2.7.0-beta.0

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.
@@ -1,7 +1,7 @@
1
- import asyncio, eventlet, eventlet.wsgi, os, requests, sys
1
+ import asyncio, eventlet, eventlet.wsgi, math, os, requests, sys
2
2
  from flask import Flask, request, jsonify
3
3
  from flask_socketio import SocketIO
4
- from kasa import Credentials, Discover, Device, UnsupportedDeviceError
4
+ from kasa import Credentials, Discover, Device, Module, UnsupportedDeviceException
5
5
  from loguru import logger
6
6
 
7
7
  app = Flask(__name__)
@@ -21,17 +21,26 @@ logger.add(RemoteLogger(logging_server_url), level="DEBUG")
21
21
 
22
22
  app.logger = logger
23
23
 
24
- device_cache = {}
25
-
26
24
  UNSUPPORTED_TYPES = {
27
25
  'SMART.IPCAMERA',
28
- 'IOT.SMARTBULB',
29
26
  'SMART.KASAHUB',
30
- 'SMART.TAPOBULB',
31
27
  'SMART.TAPOHUB'
32
28
  }
33
29
 
34
- def custom_device_serializer(device: Device):
30
+ def serialize_child(child: Device):
31
+ return {
32
+ "alias": child.alias,
33
+ **({"brightness": getattr(child.modules[Module.Light], "brightness")} if Module.Light in child.modules else {}),
34
+ **({"color_temp": getattr(child.modules[Module.Light], "color_temp")} if Module.Light in child.modules else {}),
35
+ **({"hsv": {
36
+ "hue": getattr(child.modules[Module.Light], "hsv")[0],
37
+ "saturation": getattr(child.modules[Module.Light], "hsv")[1]
38
+ }} if Module.Light in child.modules else {}),
39
+ "id": child.device_id.split("_", 1)[1],
40
+ "state": child.features["state"].value
41
+ }
42
+
43
+ def custom_sysinfo_config_serializer(device: Device):
35
44
  app.logger.debug(f"Serializing device: {device.host}")
36
45
 
37
46
  child_num = len(device.children) if device.children else 0
@@ -43,26 +52,31 @@ def custom_device_serializer(device: Device):
43
52
  "device_type": device.config.connection_type.device_family.value,
44
53
  "host": device.host,
45
54
  "hw_ver": device.hw_info["hw_ver"],
46
- "is_off": device.is_off,
47
- "is_on": device.is_on,
48
55
  "mac": device.hw_info["mac"],
49
56
  "sw_ver": device.sys_info.get("sw_ver") or device.sys_info.get("fw_ver")
50
57
  }
51
58
 
52
59
  if child_num > 0:
53
- sys_info["children"] = [
54
- {
55
- "id": child.device_id.split("_", 1)[1],
56
- "state": child.features["state"].value,
57
- "alias": child.alias
58
- } for child in device.children
59
- ]
60
+ sys_info["children"] = [serialize_child(child) for child in device.children]
60
61
  else:
61
- sys_info["state"] = device.features["state"].value
62
+ sys_info.update({
63
+ "state": device.features["state"].value,
64
+ **({"brightness": getattr(device.modules[Module.Light], "brightness")} if Module.Light in device.modules else {}),
65
+ **({"color_temp": getattr(device.modules[Module.Light], "color_temp")} if Module.Light in device.modules else {}),
66
+ **({"hsv": {
67
+ "hue": getattr(device.modules[Module.Light], "hsv")[0],
68
+ "saturation": getattr(device.modules[Module.Light], "hsv")[1]
69
+ }} if Module.Light in device.modules else {})
70
+ })
62
71
 
63
72
  device_config = {
64
73
  "host": device.config.host,
65
74
  "timeout": device.config.timeout,
75
+ **({"credentials": {
76
+ "username": device.config.credentials.username,
77
+ "password": device.config.credentials.password
78
+ }} if device.config.credentials else {
79
+ }),
66
80
  "connection_type": {
67
81
  "device_family": device.config.connection_type.device_family.value,
68
82
  "encryption_type": device.config.connection_type.encryption_type.value,
@@ -71,25 +85,27 @@ def custom_device_serializer(device: Device):
71
85
  "uses_http": device.config.uses_http
72
86
  }
73
87
 
74
- if device.config.credentials:
75
- device_config["credentials"] = {
76
- "username": device.config.credentials.username,
77
- "password": device.config.credentials.password
78
- }
79
-
80
88
  return {
81
89
  "sys_info": sys_info,
82
90
  "device_config": device_config
83
91
  }
84
92
 
85
- def custom_discovery_serializer(device: Device):
93
+ def custom_discovery_feature_serializer(device: Device):
86
94
  app.logger.debug(f"Serializing device for discovery: {device.host}")
87
95
  disc_info = {
88
96
  "model": device._discovery_info.get("device_model", device.sys_info.get("model"))
89
97
  }
90
98
 
99
+ app.logger.debug(f"Serializing device features: {device.host}")
100
+ feature_info = {
101
+ **({"brightness": device.modules[Module.Light].is_dimmable} if Module.Light in device.modules else {}),
102
+ **({"color_temp": device.modules[Module.Light].is_variable_color_temp} if Module.Light in device.modules else {}),
103
+ **({"hsv": device.modules[Module.Light].is_color} if Module.Light in device.modules else {})
104
+ }
105
+
91
106
  return {
92
- "disc_info": disc_info
107
+ "disc_info": disc_info,
108
+ "feature_info": feature_info
93
109
  }
94
110
 
95
111
  async def discover_devices(username=None, password=None, additional_broadcasts=None, manual_devices=None):
@@ -101,8 +117,8 @@ async def discover_devices(username=None, password=None, additional_broadcasts=N
101
117
  async def on_discovered(device: Device):
102
118
  try:
103
119
  await device.update()
104
- app.logger.debug(f"Discovered device: {device.host}")
105
- except UnsupportedDeviceError as e:
120
+ app.logger.debug(f"Discovered device has been updated: {device.host}")
121
+ except UnsupportedDeviceException as e:
106
122
  app.logger.warning(f"Unsupported device found during discovery: {device.host} - {str(e)}")
107
123
  except Exception as e:
108
124
  app.logger.error(f"Error updating device during discovery: {device.host} - {str(e)}")
@@ -112,26 +128,27 @@ async def discover_devices(username=None, password=None, additional_broadcasts=N
112
128
  app.logger.debug(f"Starting discovery on broadcast: {broadcast}")
113
129
  discovered_devices = await Discover.discover(target=broadcast, credentials=creds, on_discovered=on_discovered)
114
130
  for ip, dev in discovered_devices.items():
115
- devices[ip] = dev
116
- app.logger.debug(f"Added device {ip} from broadcast {broadcast} to devices list")
131
+ if ip not in devices:
132
+ devices[ip] = dev
133
+ app.logger.debug(f"Added device {ip} from broadcast {broadcast} to devices list")
117
134
  app.logger.debug(f"Discovered {len(discovered_devices)} devices on broadcast {broadcast}")
118
135
  except Exception as e:
119
136
  app.logger.error(f"Error processing broadcast {broadcast}: {str(e)}", exc_info=True)
120
137
 
121
138
  async def discover_manual_device(host):
122
139
  if host in devices:
123
- app.logger.debug(f"Manual device {host} already exists in devices, skipping.")
140
+ app.logger.debug(f"Device {host} already exists in devices list, skipping.")
124
141
  return
125
142
  try:
126
- app.logger.debug(f"Discovering manual device: {host}")
143
+ app.logger.debug(f"Starting discovery for device: {host}")
127
144
  discovered_device = await Discover.discover_single(host=host, credentials=creds)
128
- await discovered_device.update()
145
+ await on_discovered(discovered_device)
129
146
  devices[host] = discovered_device
130
- app.logger.debug(f"Discovered manual device: {host} with device type {discovered_device.device_type}")
131
- except UnsupportedDeviceError as e:
132
- app.logger.warning(f"Unsupported device found during manual discovery: {host} - {str(e)}")
147
+ app.logger.debug(f"Discovered device: {host}")
148
+ except UnsupportedDeviceException as e:
149
+ app.logger.warning(f"Unsupported device found during discovery: {host} - {str(e)}")
133
150
  except Exception as e:
134
- app.logger.error(f"Error discovering manual device {host}: {str(e)}", exc_info=True)
151
+ app.logger.error(f"Error discovering device {host}: {str(e)}", exc_info=True)
135
152
 
136
153
  await asyncio.gather(*(discover_on_broadcast(broadcast) for broadcast in broadcasts))
137
154
  await asyncio.gather(*(discover_manual_device(host) for host in manual_devices))
@@ -145,7 +162,7 @@ async def discover_devices(username=None, password=None, additional_broadcasts=N
145
162
  components = await dev._raw_query("component_nego")
146
163
  component_list = components.get("component_nego", {}).get("component_list", [])
147
164
  homekit_component = next((item for item in component_list if item.get("id") == "homekit"), None)
148
- if not dev.alias and homekit_component:
165
+ if homekit_component:
149
166
  app.logger.debug(f"Native HomeKit Support found for device {ip} and was not added to update tasks")
150
167
  continue
151
168
  except Exception:
@@ -154,10 +171,10 @@ async def discover_devices(username=None, password=None, additional_broadcasts=N
154
171
  try:
155
172
  dev_type = dev.sys_info.get("mic_type") or dev.sys_info.get("type")
156
173
  if dev_type and dev_type not in UNSUPPORTED_TYPES:
157
- tasks.append(update_device_info(ip, dev))
158
- app.logger.debug(f"Device {ip} with type {dev_type} added to update tasks")
174
+ tasks.append(create_device_info(ip, dev))
175
+ app.logger.debug(f"Device {ip} added to update tasks")
159
176
  else:
160
- app.logger.debug(f"Device {ip} with type {dev_type} is unsupported and was not added to update tasks")
177
+ app.logger.debug(f"Device {ip} is unsupported and was not added to update tasks")
161
178
  except Exception as e:
162
179
  app.logger.error(f"Error adding device {ip} to update tasks: {str(e)}")
163
180
 
@@ -173,23 +190,26 @@ async def discover_devices(username=None, password=None, additional_broadcasts=N
173
190
  app.logger.debug(f"Device discovery completed with {len(all_device_info)} devices found")
174
191
  return all_device_info
175
192
 
176
- async def update_device_info(ip, dev: Device):
177
- app.logger.debug(f"Updating device info for {ip}")
193
+ async def create_device_info(ip, dev: Device):
194
+ created_device_info = {}
195
+ app.logger.debug(f"Creating device info for {ip}")
178
196
  try:
179
- device = custom_device_serializer(dev)
180
- sys_info = device["sys_info"]
181
- device_config = device["device_config"]
182
- device = custom_discovery_serializer(dev)
183
- disc_info = device["disc_info"]
184
- device_cache[ip] = {
197
+ device_info = custom_sysinfo_config_serializer(dev)
198
+ sys_info = device_info["sys_info"]
199
+ device_config = device_info["device_config"]
200
+ device_info = custom_discovery_feature_serializer(dev)
201
+ disc_info = device_info["disc_info"]
202
+ feature_info = device_info["feature_info"]
203
+ created_device_info[ip] = {
185
204
  "sys_info": sys_info,
186
205
  "disc_info": disc_info,
206
+ "feature_info": feature_info,
187
207
  "device_config": device_config
188
208
  }
189
- app.logger.debug(f"Updated device info for {ip}")
190
- return ip, device_cache[ip]
209
+ app.logger.debug(f"Created device info for {ip}")
210
+ return ip, created_device_info[ip]
191
211
  except Exception as e:
192
- app.logger.error(f"Error updating device info for {ip}: {str(e)}")
212
+ app.logger.error(f"Error creating device info for {ip}: {str(e)}")
193
213
  return ip, {}
194
214
 
195
215
  async def get_sys_info(device_config):
@@ -200,7 +220,7 @@ async def get_sys_info(device_config):
200
220
  app.logger.warning(f"Alias not found for device {dev.host}. Reconnecting and updating...")
201
221
  await dev.disconnect()
202
222
  dev = await Device.connect(config=Device.Config.from_dict(device_config))
203
- device = custom_device_serializer(dev)
223
+ device = custom_sysinfo_config_serializer(dev)
204
224
  sys_info = device["sys_info"]
205
225
  return {"sys_info": sys_info}
206
226
  except Exception as e:
@@ -209,27 +229,55 @@ async def get_sys_info(device_config):
209
229
  finally:
210
230
  await dev.disconnect()
211
231
 
212
- async def control_device(device_config, action, child_num=None):
213
- if child_num is not None:
214
- app.logger.debug(f"Controlling device: {device_config['host']}, action: {action}, child_num: {child_num}")
215
- else:
216
- app.logger.debug(f"Controlling device: {device_config['host']}, action: {action}")
232
+ async def control_device(device_config, feature, action, value, child_num=None):
233
+ app.logger.debug(f"Controlling device: {device_config['host']}, feature: {feature}, action: {action}, child_num: {child_num if child_num is not None else 'N/A'}")
217
234
 
218
235
  dev = await Device.connect(config=Device.Config.from_dict(device_config))
219
236
  try:
220
- if child_num is not None:
221
- child = dev.children[child_num]
222
- await getattr(child, action)()
237
+ target = dev.children[child_num] if child_num is not None else dev
238
+
239
+ if feature == "state":
240
+ await getattr(target, action)()
241
+ elif feature == "brightness":
242
+ await handle_brightness(target, action, value)
243
+ elif feature == "color_temp":
244
+ await handle_color_temp(target, action, value)
245
+ elif feature in ["hue", "saturation"]:
246
+ await handle_hsv(target, action, feature, value)
223
247
  else:
224
- await getattr(dev, action)()
225
- app.logger.debug(f"Controlled device {device_config['host']} with action: {action}")
226
- return {"status": "success", f"is_{action.split('_')[1]}": True}
248
+ raise Exception(f"Unsupported feature: {feature}")
249
+
250
+ app.logger.debug(f"Controlled device {device_config['host']} with feature: {feature}, action: {action}, child_num: {child_num if child_num is not None else 'N/A'}")
251
+ return {"status": "success"}
227
252
  except Exception as e:
228
253
  app.logger.error(f"Error controlling device {device_config['host']}: {str(e)}")
229
254
  return {"status": "error", "message": str(e)}
230
255
  finally:
231
256
  await dev.disconnect()
232
257
 
258
+ async def handle_brightness(target: Device, action, value):
259
+ if value == 0:
260
+ await target.turn_off()
261
+ elif value > 0 and value < 100:
262
+ light = target.modules[Module.Light]
263
+ await getattr(light, action)(value)
264
+ else:
265
+ await target.turn_on()
266
+
267
+ async def handle_color_temp(target: Device, action, value):
268
+ light = target.modules[Module.Light]
269
+ range = light.valid_temperature_range
270
+ if value < range[0]:
271
+ value = range[0]
272
+ elif value > range[1]:
273
+ value = range[1]
274
+ await getattr(light, action)(value)
275
+
276
+ async def handle_hsv(target: Device, action, feature, value):
277
+ light = target.modules[Module.Light]
278
+ hsv_value = {feature: value[feature]}
279
+ await getattr(light, action)(hsv_value)
280
+
233
281
  def run_async(func, *args):
234
282
  loop = asyncio.get_event_loop()
235
283
  return loop.run_until_complete(func(*args))
@@ -266,10 +314,12 @@ def control_device_route():
266
314
  device_config = data['device_config']
267
315
  credentials = device_config.get('credentials')
268
316
  device_config.update({'credentials': Credentials(username=credentials['username'], password=credentials['password'])} if credentials else {})
317
+ feature = data['feature']
269
318
  action = data['action']
319
+ value = data['value']
270
320
  child_num = data.get('child_num')
271
321
  app.logger.debug(f"Controlling device: {device_config['host']}, action: {action}, child_num: {child_num}")
272
- result = run_async(control_device, device_config, action, child_num)
322
+ result = run_async(control_device, device_config, feature, action, value, child_num)
273
323
  return jsonify(result)
274
324
 
275
325
  @app.route('/health', methods=['GET'])
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Homebridge Kasa Python",
3
3
  "name": "homebridge-kasa-python",
4
- "version": "2.6.13",
4
+ "version": "2.7.0-beta.0",
5
5
  "description": "Plugin that uses Python-Kasa API to communicate with Kasa Devices.",
6
6
  "license": "MIT",
7
7
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/ZeliardM/homebridge-kasa-python/issues"
14
14
  },
15
15
  "engines": {
16
- "node": "^18.20.5 || ^20.18.1 || ^22.11.0 || ^23.3.0",
16
+ "node": "^18.20.5 || ^20.18.1 || ^22.12.0 || ^23.3.0",
17
17
  "homebridge": "^1.8.0 || ^2.0.0-beta.0",
18
18
  "python": "^3.9.0"
19
19
  },
@@ -47,20 +47,20 @@
47
47
  ],
48
48
  "devDependencies": {
49
49
  "@eslint/eslintrc": "^3.2.0",
50
- "@eslint/js": "^9.15.0",
50
+ "@eslint/js": "^9.16.0",
51
51
  "@stylistic/eslint-plugin": "^2.11.0",
52
52
  "@types/lodash.defaults": "^4.2.9",
53
- "@types/node": "^22.9.1",
53
+ "@types/node": "^22.10.1",
54
54
  "@types/semver": "^7.5.8",
55
- "@typescript-eslint/parser": "^8.15.0",
56
- "eslint": "^9.15.0",
57
- "globals": "^15.12.0",
55
+ "@typescript-eslint/parser": "^8.17.0",
56
+ "eslint": "^9.16.0",
57
+ "globals": "^15.13.0",
58
58
  "homebridge": "^2.0.0-beta.23",
59
59
  "nodemon": "^3.1.7",
60
60
  "node-persist": "^4.0.3",
61
61
  "rimraf": "^6.0.1",
62
62
  "ts-node": "^10.9.2",
63
- "typescript-eslint": "^8.15.0"
63
+ "typescript-eslint": "^8.17.0"
64
64
  },
65
65
  "homepage": "https://github.com/ZeliardM/homebridge-kasa-python#readme",
66
66
  "funding": [
@@ -76,12 +76,12 @@
76
76
  "dependencies": {
77
77
  "ajv": "^8.17.1",
78
78
  "ajv-formats": "^3.0.1",
79
- "axios": "^1.7.7",
79
+ "axios": "^1.7.9",
80
80
  "get-port": "^7.1.0",
81
81
  "lodash.defaults": "^4.2.0",
82
82
  "semver": "^7.6.3",
83
83
  "ts-essentials": "^10.0.3",
84
- "typescript": "^5.6.3",
84
+ "typescript": "^5.7.2",
85
85
  "util": "^0.12.5"
86
86
  },
87
87
  "overrides": {
package/requirements.txt CHANGED
@@ -2,5 +2,5 @@ eventlet==0.38.0
2
2
  flask==3.1.0
3
3
  flask_socketio==5.4.1
4
4
  loguru==0.7.2
5
- requests==2.32.3
6
- python-kasa==0.7.7
5
+ python-kasa==0.8.0
6
+ requests==2.32.3