pygpt-net 2.6.27__py3-none-any.whl → 2.6.28__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.
- pygpt_net/CHANGELOG.txt +6 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +5 -1
- pygpt_net/controller/kernel/kernel.py +2 -0
- pygpt_net/controller/notepad/notepad.py +10 -1
- pygpt_net/controller/theme/markdown.py +2 -0
- pygpt_net/controller/ui/tabs.py +5 -0
- pygpt_net/core/audio/backend/native.py +1 -3
- pygpt_net/core/command/command.py +2 -0
- pygpt_net/core/render/web/helpers.py +13 -3
- pygpt_net/core/render/web/renderer.py +3 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/css/web-blocks.darkest.css +91 -0
- pygpt_net/data/css/web-chatgpt.css +7 -5
- pygpt_net/data/css/web-chatgpt.dark.css +5 -2
- pygpt_net/data/css/web-chatgpt.darkest.css +91 -0
- pygpt_net/data/css/web-chatgpt.light.css +8 -2
- pygpt_net/data/css/web-chatgpt_wide.css +7 -4
- pygpt_net/data/css/web-chatgpt_wide.dark.css +5 -2
- pygpt_net/data/css/web-chatgpt_wide.darkest.css +91 -0
- pygpt_net/data/css/web-chatgpt_wide.light.css +9 -6
- pygpt_net/data/themes/dark_darkest.css +31 -0
- pygpt_net/data/themes/dark_darkest.xml +10 -0
- pygpt_net/plugin/tuya/__init__.py +12 -0
- pygpt_net/plugin/tuya/config.py +256 -0
- pygpt_net/plugin/tuya/plugin.py +117 -0
- pygpt_net/plugin/tuya/worker.py +588 -0
- pygpt_net/plugin/wikipedia/__init__.py +12 -0
- pygpt_net/plugin/wikipedia/config.py +228 -0
- pygpt_net/plugin/wikipedia/plugin.py +114 -0
- pygpt_net/plugin/wikipedia/worker.py +430 -0
- pygpt_net/provider/core/config/patch.py +11 -0
- pygpt_net/ui/widget/tabs/output.py +2 -0
- pygpt_net/ui/widget/textarea/input.py +10 -7
- {pygpt_net-2.6.27.dist-info → pygpt_net-2.6.28.dist-info}/METADATA +40 -2
- {pygpt_net-2.6.27.dist-info → pygpt_net-2.6.28.dist-info}/RECORD +40 -27
- {pygpt_net-2.6.27.dist-info → pygpt_net-2.6.28.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.27.dist-info → pygpt_net-2.6.28.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.27.dist-info → pygpt_net-2.6.28.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# ================================================== #
|
|
4
|
+
# This file is a part of PYGPT package #
|
|
5
|
+
# Website: https://pygpt.net #
|
|
6
|
+
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
+
# MIT License #
|
|
8
|
+
# Created By : Marcin Szczygliński #
|
|
9
|
+
# Updated Date: 2025.08.27 20:00:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import time
|
|
16
|
+
import hmac
|
|
17
|
+
import hashlib
|
|
18
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
19
|
+
from urllib.parse import urlencode
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
from PySide6.QtCore import Slot
|
|
23
|
+
|
|
24
|
+
from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkerSignals(BaseSignals):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Worker(BaseWorker):
|
|
32
|
+
"""
|
|
33
|
+
Tuya Smart Home plugin worker: Auth (token), Devices, Status, Control, Sensors.
|
|
34
|
+
Uses Tuya Cloud API (v1.0) with proper "new signature" HMAC-SHA256 signing.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, *args, **kwargs):
|
|
38
|
+
super(Worker, self).__init__()
|
|
39
|
+
self.signals = WorkerSignals()
|
|
40
|
+
self.args = args
|
|
41
|
+
self.kwargs = kwargs
|
|
42
|
+
self.plugin = None
|
|
43
|
+
self.cmds = None
|
|
44
|
+
self.ctx = None
|
|
45
|
+
self.msg = None
|
|
46
|
+
|
|
47
|
+
# ---------------------- Core runner ----------------------
|
|
48
|
+
|
|
49
|
+
@Slot()
|
|
50
|
+
def run(self):
|
|
51
|
+
try:
|
|
52
|
+
responses = []
|
|
53
|
+
for item in self.cmds:
|
|
54
|
+
if self.is_stopped():
|
|
55
|
+
break
|
|
56
|
+
try:
|
|
57
|
+
response = None
|
|
58
|
+
if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
|
|
59
|
+
|
|
60
|
+
# -------- Auth --------
|
|
61
|
+
if item["cmd"] == "tuya_set_keys":
|
|
62
|
+
response = self.cmd_tuya_set_keys(item)
|
|
63
|
+
elif item["cmd"] == "tuya_set_uid":
|
|
64
|
+
response = self.cmd_tuya_set_uid(item)
|
|
65
|
+
elif item["cmd"] == "tuya_token_get":
|
|
66
|
+
response = self.cmd_tuya_token_get(item)
|
|
67
|
+
|
|
68
|
+
# -------- Devices / Info --------
|
|
69
|
+
elif item["cmd"] == "tuya_devices_list":
|
|
70
|
+
response = self.cmd_tuya_devices_list(item)
|
|
71
|
+
elif item["cmd"] == "tuya_device_get":
|
|
72
|
+
response = self.cmd_tuya_device_get(item)
|
|
73
|
+
elif item["cmd"] == "tuya_device_status":
|
|
74
|
+
response = self.cmd_tuya_device_status(item)
|
|
75
|
+
elif item["cmd"] == "tuya_device_functions":
|
|
76
|
+
response = self.cmd_tuya_device_functions(item)
|
|
77
|
+
elif item["cmd"] == "tuya_find_device":
|
|
78
|
+
response = self.cmd_tuya_find_device(item)
|
|
79
|
+
|
|
80
|
+
# -------- Control --------
|
|
81
|
+
elif item["cmd"] == "tuya_device_set":
|
|
82
|
+
response = self.cmd_tuya_device_set(item)
|
|
83
|
+
elif item["cmd"] == "tuya_device_send":
|
|
84
|
+
response = self.cmd_tuya_device_send(item)
|
|
85
|
+
elif item["cmd"] == "tuya_device_on":
|
|
86
|
+
response = self.cmd_tuya_device_on(item)
|
|
87
|
+
elif item["cmd"] == "tuya_device_off":
|
|
88
|
+
response = self.cmd_tuya_device_off(item)
|
|
89
|
+
elif item["cmd"] == "tuya_device_toggle":
|
|
90
|
+
response = self.cmd_tuya_device_toggle(item)
|
|
91
|
+
|
|
92
|
+
# -------- Sensors --------
|
|
93
|
+
elif item["cmd"] == "tuya_sensors_read":
|
|
94
|
+
response = self.cmd_tuya_sensors_read(item)
|
|
95
|
+
|
|
96
|
+
if response:
|
|
97
|
+
responses.append(response)
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
responses.append(self.make_response(item, self.throw_error(e)))
|
|
101
|
+
|
|
102
|
+
if responses:
|
|
103
|
+
self.reply_more(responses)
|
|
104
|
+
if self.msg is not None:
|
|
105
|
+
self.status(self.msg)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
self.error(e)
|
|
108
|
+
finally:
|
|
109
|
+
self.cleanup()
|
|
110
|
+
|
|
111
|
+
# ---------------------- HTTP / Sign helpers ----------------------
|
|
112
|
+
|
|
113
|
+
def _api_base(self) -> str:
|
|
114
|
+
return (self.plugin.get_option_value("api_base") or "https://openapi.tuyaeu.com").rstrip("/")
|
|
115
|
+
|
|
116
|
+
def _timeout(self) -> int:
|
|
117
|
+
try:
|
|
118
|
+
return int(self.plugin.get_option_value("http_timeout") or 30)
|
|
119
|
+
except Exception:
|
|
120
|
+
return 30
|
|
121
|
+
|
|
122
|
+
def _lang(self) -> str:
|
|
123
|
+
return (self.plugin.get_option_value("lang") or "en").strip() or "en"
|
|
124
|
+
|
|
125
|
+
def _client(self) -> Tuple[str, str]:
|
|
126
|
+
cid = (self.plugin.get_option_value("tuya_client_id") or "").strip()
|
|
127
|
+
sec = (self.plugin.get_option_value("tuya_client_secret") or "").strip()
|
|
128
|
+
if not cid or not sec:
|
|
129
|
+
raise RuntimeError("Set Tuya Client ID and Client Secret first.")
|
|
130
|
+
return cid, sec
|
|
131
|
+
|
|
132
|
+
def _now_ms_str(self) -> str:
|
|
133
|
+
return str(int(time.time() * 1000))
|
|
134
|
+
|
|
135
|
+
def _hmac_sha256_upper(self, key: str, msg: str) -> str:
|
|
136
|
+
return hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest().upper()
|
|
137
|
+
|
|
138
|
+
def _sha256_hex(self, s: str) -> str:
|
|
139
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
140
|
+
|
|
141
|
+
def _json_canonical(self, body: Union[dict, list, None]) -> str:
|
|
142
|
+
# Canonical JSON to ensure hash matches the actual sent body
|
|
143
|
+
if body is None:
|
|
144
|
+
return ""
|
|
145
|
+
return json.dumps(body, separators=(",", ":"), sort_keys=True, ensure_ascii=False)
|
|
146
|
+
|
|
147
|
+
def _sorted_params_list(self, params: Optional[dict | List[Tuple[str, Any]]]) -> List[Tuple[str, str]]:
|
|
148
|
+
if not params:
|
|
149
|
+
return []
|
|
150
|
+
if isinstance(params, list):
|
|
151
|
+
# Convert values to str, preserve provided order
|
|
152
|
+
return [(k, str(v)) for (k, v) in params]
|
|
153
|
+
out: List[Tuple[str, str]] = []
|
|
154
|
+
for k in sorted(params.keys()):
|
|
155
|
+
v = params[k]
|
|
156
|
+
if isinstance(v, list):
|
|
157
|
+
for vv in v:
|
|
158
|
+
out.append((k, str(vv)))
|
|
159
|
+
else:
|
|
160
|
+
out.append((k, str(v)))
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
def _canonical_qs(self, params: Optional[dict | List[Tuple[str, Any]]]) -> str:
|
|
164
|
+
items = self._sorted_params_list(params)
|
|
165
|
+
if not items:
|
|
166
|
+
return ""
|
|
167
|
+
return urlencode(items, doseq=True)
|
|
168
|
+
|
|
169
|
+
def _token_cached(self) -> Tuple[Optional[str], int]:
|
|
170
|
+
tok = (self.plugin.get_option_value("tuya_access_token") or "").strip()
|
|
171
|
+
exp_at = 0
|
|
172
|
+
try:
|
|
173
|
+
exp_at = int(self.plugin.get_option_value("tuya_token_expire_at") or 0)
|
|
174
|
+
except Exception:
|
|
175
|
+
exp_at = 0
|
|
176
|
+
return (tok if tok else None), exp_at
|
|
177
|
+
|
|
178
|
+
def _token_valid(self) -> bool:
|
|
179
|
+
tok, exp_at = self._token_cached()
|
|
180
|
+
if not tok:
|
|
181
|
+
return False
|
|
182
|
+
return int(time.time()) < exp_at
|
|
183
|
+
|
|
184
|
+
def _ensure_token(self):
|
|
185
|
+
if not self._token_valid():
|
|
186
|
+
self._token_get()
|
|
187
|
+
|
|
188
|
+
def _build_string_to_sign(self, method: str, path: str, params: Optional[dict | List[Tuple[str, Any]]], body_str: str) -> str:
|
|
189
|
+
# signature: stringToSign = method + "\n" + contentSHA256 + "\n" + headersStr + "\n" + urlPathWithQuery
|
|
190
|
+
method = (method or "GET").upper()
|
|
191
|
+
content_sha256 = self._sha256_hex(body_str or "")
|
|
192
|
+
headers_str = "" # We don't sign headers (optional feature)
|
|
193
|
+
qs = self._canonical_qs(params)
|
|
194
|
+
url_part = path if path.startswith("/") else "/" + path
|
|
195
|
+
if qs:
|
|
196
|
+
url_part = f"{url_part}?{qs}"
|
|
197
|
+
return "\n".join([method, content_sha256, headers_str, url_part])
|
|
198
|
+
|
|
199
|
+
def _headers(
|
|
200
|
+
self,
|
|
201
|
+
method: str,
|
|
202
|
+
path: str,
|
|
203
|
+
params: Optional[dict | List[Tuple[str, Any]]] = None,
|
|
204
|
+
body_str: str = "",
|
|
205
|
+
) -> Dict[str, str]:
|
|
206
|
+
cid, sec = self._client()
|
|
207
|
+
t = self._now_ms_str()
|
|
208
|
+
tok, _ = self._token_cached()
|
|
209
|
+
# Do not include token in sign for /v1.0/token
|
|
210
|
+
include_token = bool(tok) and not path.startswith("/v1.0/token")
|
|
211
|
+
|
|
212
|
+
string_to_sign = self._build_string_to_sign(method, path, params, body_str)
|
|
213
|
+
sign_src = cid + (tok if include_token else "") + t + string_to_sign
|
|
214
|
+
sign = self._hmac_sha256_upper(sec, sign_src)
|
|
215
|
+
|
|
216
|
+
hdrs = {
|
|
217
|
+
"client_id": cid,
|
|
218
|
+
"sign": sign,
|
|
219
|
+
"t": t,
|
|
220
|
+
"sign_method": "HMAC-SHA256",
|
|
221
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
222
|
+
"lang": self._lang(),
|
|
223
|
+
}
|
|
224
|
+
if include_token:
|
|
225
|
+
hdrs["access_token"] = tok
|
|
226
|
+
return hdrs
|
|
227
|
+
|
|
228
|
+
def _handle_tuya_response(self, r: requests.Response) -> dict:
|
|
229
|
+
try:
|
|
230
|
+
payload = r.json() if r.content else {}
|
|
231
|
+
except Exception:
|
|
232
|
+
raise RuntimeError(f"HTTP {r.status_code}: Non-JSON response: {r.text}")
|
|
233
|
+
|
|
234
|
+
if r.status_code < 200 or r.status_code >= 300:
|
|
235
|
+
raise RuntimeError(json.dumps({"status": r.status_code, "error": payload}, ensure_ascii=False))
|
|
236
|
+
|
|
237
|
+
if payload.get("success") is False:
|
|
238
|
+
code = payload.get("code")
|
|
239
|
+
msg = payload.get("msg") or payload.get("message")
|
|
240
|
+
# Common sign error codes often: 1010, "sign invalid"
|
|
241
|
+
raise RuntimeError(json.dumps({
|
|
242
|
+
"status": r.status_code,
|
|
243
|
+
"success": False,
|
|
244
|
+
"code": code,
|
|
245
|
+
"error": msg,
|
|
246
|
+
"detail": payload
|
|
247
|
+
}, ensure_ascii=False))
|
|
248
|
+
|
|
249
|
+
return payload
|
|
250
|
+
|
|
251
|
+
def _request(
|
|
252
|
+
self,
|
|
253
|
+
method: str,
|
|
254
|
+
path: str,
|
|
255
|
+
params: Optional[dict | List[Tuple[str, Any]]] = None,
|
|
256
|
+
body: Optional[dict | list] = None,
|
|
257
|
+
require_token: bool = True,
|
|
258
|
+
) -> dict:
|
|
259
|
+
if not path.startswith("/"):
|
|
260
|
+
path = "/" + path
|
|
261
|
+
|
|
262
|
+
# Don't fetch token for token endpoint
|
|
263
|
+
if require_token and not path.startswith("/v1.0/token"):
|
|
264
|
+
self._ensure_token()
|
|
265
|
+
|
|
266
|
+
url = f"{self._api_base()}{path}"
|
|
267
|
+
params_items = self._sorted_params_list(params)
|
|
268
|
+
|
|
269
|
+
body_str = self._json_canonical(body) if method.upper() in ("POST", "PUT", "PATCH") else ""
|
|
270
|
+
headers = self._headers(method, path, params_items, body_str)
|
|
271
|
+
|
|
272
|
+
# Use data=body_str to ensure the signed body equals the sent body
|
|
273
|
+
if method.upper() == "GET":
|
|
274
|
+
r = requests.get(url, headers=headers, params=params_items, timeout=self._timeout())
|
|
275
|
+
elif method.upper() == "POST":
|
|
276
|
+
r = requests.post(url, headers=headers, params=params_items, data=body_str, timeout=self._timeout())
|
|
277
|
+
elif method.upper() == "DELETE":
|
|
278
|
+
r = requests.delete(url, headers=headers, params=params_items, data=body_str, timeout=self._timeout())
|
|
279
|
+
elif method.upper() == "PUT":
|
|
280
|
+
r = requests.put(url, headers=headers, params=params_items, data=body_str, timeout=self._timeout())
|
|
281
|
+
elif method.upper() == "PATCH":
|
|
282
|
+
r = requests.patch(url, headers=headers, params=params_items, data=body_str, timeout=self._timeout())
|
|
283
|
+
else:
|
|
284
|
+
raise RuntimeError(f"Unsupported method: {method}")
|
|
285
|
+
|
|
286
|
+
return self._handle_tuya_response(r)
|
|
287
|
+
|
|
288
|
+
def _get(self, path: str, params: Optional[dict | List[Tuple[str, Any]]] = None) -> dict:
|
|
289
|
+
return self._request("GET", path, params=params, body=None, require_token=not path.startswith("/v1.0/token"))
|
|
290
|
+
|
|
291
|
+
def _post_json(self, path: str, payload: dict | list | None = None, params: Optional[dict | List[Tuple[str, Any]]] = None) -> dict:
|
|
292
|
+
return self._request("POST", path, params=params, body=(payload or {}), require_token=not path.startswith("/v1.0/token"))
|
|
293
|
+
|
|
294
|
+
# ---------------------- Token management ----------------------
|
|
295
|
+
|
|
296
|
+
def _token_get(self) -> dict:
|
|
297
|
+
# signature for GET /v1.0/token?grant_type=1 (no access_token in sign)
|
|
298
|
+
res = self._request("GET", "/v1.0/token", params={"grant_type": "1"}, body=None, require_token=False)
|
|
299
|
+
result = res.get("result") or {}
|
|
300
|
+
access_token = (result.get("access_token") or "").strip()
|
|
301
|
+
expire_time = int(result.get("expire_time") or 0)
|
|
302
|
+
if not access_token:
|
|
303
|
+
raise RuntimeError("Token retrieval failed: missing access_token.")
|
|
304
|
+
|
|
305
|
+
exp_at = int(time.time()) + max(0, expire_time - 60)
|
|
306
|
+
self.plugin.set_option_value("tuya_access_token", access_token)
|
|
307
|
+
self.plugin.set_option_value("tuya_token_expires_in", str(expire_time))
|
|
308
|
+
self.plugin.set_option_value("tuya_token_expire_at", str(exp_at))
|
|
309
|
+
if result.get("refresh_token"):
|
|
310
|
+
self.plugin.set_option_value("tuya_refresh_token", result["refresh_token"])
|
|
311
|
+
return res
|
|
312
|
+
|
|
313
|
+
# ---------------------- Device helpers ----------------------
|
|
314
|
+
|
|
315
|
+
def _get_uid(self, p: dict) -> str:
|
|
316
|
+
uid = (p.get("uid") or self.plugin.get_option_value("tuya_uid") or "").strip()
|
|
317
|
+
if not uid:
|
|
318
|
+
raise RuntimeError("Missing UID. Set 'tuya_uid' in options or pass 'uid' param.")
|
|
319
|
+
return uid
|
|
320
|
+
|
|
321
|
+
def _device_status(self, device_id: str) -> List[dict]:
|
|
322
|
+
res = self._get(f"/v1.0/devices/{device_id}/status")
|
|
323
|
+
return res.get("result") or res.get("data") or []
|
|
324
|
+
|
|
325
|
+
def _device_info(self, device_id: str) -> dict:
|
|
326
|
+
res = self._get(f"/v1.0/devices/{device_id}")
|
|
327
|
+
return res.get("result") or res.get("data") or {}
|
|
328
|
+
|
|
329
|
+
def _device_functions(self, device_id: str) -> List[dict]:
|
|
330
|
+
res = self._get(f"/v1.0/devices/{device_id}/functions")
|
|
331
|
+
data = res.get("result") or res.get("data") or {}
|
|
332
|
+
funcs = data.get("functions") or data.get("result") or data
|
|
333
|
+
if isinstance(funcs, dict):
|
|
334
|
+
funcs = funcs.get("functions") or []
|
|
335
|
+
if not isinstance(funcs, list):
|
|
336
|
+
funcs = []
|
|
337
|
+
return funcs
|
|
338
|
+
|
|
339
|
+
def _guess_switch_code(self, device_id: str) -> str:
|
|
340
|
+
candidates = [
|
|
341
|
+
"switch", "switch_main", "switch_led", "power_switch",
|
|
342
|
+
"switch_1", "switch_2", "switch_3", "switch_usb1", "on_off",
|
|
343
|
+
"master_switch", "relay_switch", "socket1"
|
|
344
|
+
]
|
|
345
|
+
try:
|
|
346
|
+
funcs = self._device_functions(device_id)
|
|
347
|
+
codes = [f.get("code") for f in funcs if isinstance(f, dict)]
|
|
348
|
+
for c in candidates:
|
|
349
|
+
if c in codes:
|
|
350
|
+
return c
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
try:
|
|
354
|
+
status = self._device_status(device_id)
|
|
355
|
+
codes = [s.get("code") for s in status if isinstance(s, dict) and isinstance(s.get("value"), bool)]
|
|
356
|
+
for c in candidates:
|
|
357
|
+
if c in codes:
|
|
358
|
+
return c
|
|
359
|
+
if codes:
|
|
360
|
+
return codes[0]
|
|
361
|
+
except Exception:
|
|
362
|
+
pass
|
|
363
|
+
return "switch"
|
|
364
|
+
|
|
365
|
+
def _build_commands(self, mapping: Dict[str, Any]) -> List[dict]:
|
|
366
|
+
return [{"code": k, "value": v} for k, v in mapping.items()]
|
|
367
|
+
|
|
368
|
+
def _device_send_commands(self, device_id: str, commands: List[dict]) -> dict:
|
|
369
|
+
payload = {"commands": commands}
|
|
370
|
+
res = self._post_json(f"/v1.0/devices/{device_id}/commands", payload=payload)
|
|
371
|
+
return res
|
|
372
|
+
|
|
373
|
+
def _toggle_state(self, device_id: str, code: Optional[str] = None) -> Tuple[str, Optional[bool]]:
|
|
374
|
+
code = code or self._guess_switch_code(device_id)
|
|
375
|
+
status = self._device_status(device_id)
|
|
376
|
+
current = None
|
|
377
|
+
for s in status:
|
|
378
|
+
if s.get("code") == code and isinstance(s.get("value"), bool):
|
|
379
|
+
current = bool(s["value"])
|
|
380
|
+
break
|
|
381
|
+
if current is None:
|
|
382
|
+
for s in status:
|
|
383
|
+
if isinstance(s.get("value"), bool):
|
|
384
|
+
code = s["code"]
|
|
385
|
+
current = bool(s["value"])
|
|
386
|
+
break
|
|
387
|
+
return code, current
|
|
388
|
+
|
|
389
|
+
def _normalize_sensors(self, status_list: List[dict]) -> dict:
|
|
390
|
+
res = {"raw": status_list, "normalized": {}}
|
|
391
|
+
idx = {s.get("code"): s.get("value") for s in status_list if isinstance(s, dict) and "code" in s}
|
|
392
|
+
norm = {}
|
|
393
|
+
|
|
394
|
+
def deki(v):
|
|
395
|
+
try:
|
|
396
|
+
if isinstance(v, (int, float)):
|
|
397
|
+
return round(v / 10.0, 1) if abs(v) < 1000 else v
|
|
398
|
+
except Exception:
|
|
399
|
+
pass
|
|
400
|
+
return v
|
|
401
|
+
|
|
402
|
+
for c in ["temp_current", "temperature", "temp_value", "va_temperature", "temp_cur"]:
|
|
403
|
+
if c in idx:
|
|
404
|
+
norm["temperature_c"] = deki(idx[c])
|
|
405
|
+
break
|
|
406
|
+
for c in ["humidity_value", "humidity", "va_humidity", "hum_value"]:
|
|
407
|
+
if c in idx:
|
|
408
|
+
norm["humidity_pct"] = deki(idx[c])
|
|
409
|
+
break
|
|
410
|
+
for c in ["co2", "co2_value"]:
|
|
411
|
+
if c in idx:
|
|
412
|
+
norm["co2_ppm"] = idx[c]
|
|
413
|
+
break
|
|
414
|
+
for c in ["pm25", "pm2p5", "pm2_5"]:
|
|
415
|
+
if c in idx:
|
|
416
|
+
norm["pm25_ugm3"] = idx[c]
|
|
417
|
+
break
|
|
418
|
+
for c in ["pm10", "pm_10"]:
|
|
419
|
+
if c in idx:
|
|
420
|
+
norm["pm10_ugm3"] = idx[c]
|
|
421
|
+
break
|
|
422
|
+
for c in ["illumination", "illumination_value", "lux", "bright_value"]:
|
|
423
|
+
if c in idx:
|
|
424
|
+
norm["illuminance_lux"] = idx[c]
|
|
425
|
+
break
|
|
426
|
+
for c in ["battery", "battery_percentage", "battery_state"]:
|
|
427
|
+
if c in idx:
|
|
428
|
+
norm["battery_pct_or_state"] = idx[c]
|
|
429
|
+
break
|
|
430
|
+
|
|
431
|
+
res["normalized"] = norm
|
|
432
|
+
return res
|
|
433
|
+
|
|
434
|
+
# ---------------------- Commands: Auth ----------------------
|
|
435
|
+
|
|
436
|
+
def cmd_tuya_set_keys(self, item: dict) -> dict:
|
|
437
|
+
p = item.get("params", {})
|
|
438
|
+
cid = (p.get("client_id") or "").strip()
|
|
439
|
+
sec = (p.get("client_secret") or "").strip()
|
|
440
|
+
if not cid or not sec:
|
|
441
|
+
return self.make_response(item, "Params 'client_id' and 'client_secret' required")
|
|
442
|
+
self.plugin.set_option_value("tuya_client_id", cid)
|
|
443
|
+
self.plugin.set_option_value("tuya_client_secret", sec)
|
|
444
|
+
return self.make_response(item, {"ok": True})
|
|
445
|
+
|
|
446
|
+
def cmd_tuya_set_uid(self, item: dict) -> dict:
|
|
447
|
+
p = item.get("params", {})
|
|
448
|
+
uid = (p.get("uid") or "").strip()
|
|
449
|
+
if not uid:
|
|
450
|
+
return self.make_response(item, "Param 'uid' required")
|
|
451
|
+
self.plugin.set_option_value("tuya_uid", uid)
|
|
452
|
+
return self.make_response(item, {"ok": True})
|
|
453
|
+
|
|
454
|
+
def cmd_tuya_token_get(self, item: dict) -> dict:
|
|
455
|
+
res = self._token_get()
|
|
456
|
+
return self.make_response(item, res)
|
|
457
|
+
|
|
458
|
+
# ---------------------- Commands: Devices / Info ----------------------
|
|
459
|
+
|
|
460
|
+
def cmd_tuya_devices_list(self, item: dict) -> dict:
|
|
461
|
+
p = item.get("params", {})
|
|
462
|
+
uid = self._get_uid(p)
|
|
463
|
+
page_no = int(p.get("page_no") or 1)
|
|
464
|
+
page_size = int(p.get("page_size") or 100)
|
|
465
|
+
params = {"page_no": page_no, "page_size": page_size}
|
|
466
|
+
res = self._get(f"/v1.0/users/{uid}/devices", params=params)
|
|
467
|
+
try:
|
|
468
|
+
devices = (res.get("result") or {}).get("devices") or res.get("result") or []
|
|
469
|
+
self.plugin.set_option_value("tuya_cached_devices", json.dumps(devices, ensure_ascii=False))
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
return self.make_response(item, res)
|
|
473
|
+
|
|
474
|
+
def cmd_tuya_device_get(self, item: dict) -> dict:
|
|
475
|
+
p = item.get("params", {})
|
|
476
|
+
device_id = p.get("device_id")
|
|
477
|
+
if not device_id:
|
|
478
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
479
|
+
res = self._get(f"/v1.0/devices/{device_id}")
|
|
480
|
+
return self.make_response(item, res)
|
|
481
|
+
|
|
482
|
+
def cmd_tuya_device_status(self, item: dict) -> dict:
|
|
483
|
+
p = item.get("params", {})
|
|
484
|
+
device_id = p.get("device_id")
|
|
485
|
+
if not device_id:
|
|
486
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
487
|
+
res = self._get(f"/v1.0/devices/{device_id}/status")
|
|
488
|
+
return self.make_response(item, res)
|
|
489
|
+
|
|
490
|
+
def cmd_tuya_device_functions(self, item: dict) -> dict:
|
|
491
|
+
p = item.get("params", {})
|
|
492
|
+
device_id = p.get("device_id")
|
|
493
|
+
if not device_id:
|
|
494
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
495
|
+
res = self._get(f"/v1.0/devices/{device_id}/functions")
|
|
496
|
+
return self.make_response(item, res)
|
|
497
|
+
|
|
498
|
+
def cmd_tuya_find_device(self, item: dict) -> dict:
|
|
499
|
+
p = item.get("params", {})
|
|
500
|
+
name = (p.get("name") or "").strip().lower()
|
|
501
|
+
if not name:
|
|
502
|
+
return self.make_response(item, "Param 'name' required")
|
|
503
|
+
try:
|
|
504
|
+
cached = self.plugin.get_option_value("tuya_cached_devices") or "[]"
|
|
505
|
+
devices = json.loads(cached)
|
|
506
|
+
except Exception:
|
|
507
|
+
devices = []
|
|
508
|
+
found = []
|
|
509
|
+
for d in devices:
|
|
510
|
+
n = str(d.get("name") or "").lower()
|
|
511
|
+
if name in n:
|
|
512
|
+
found.append(d)
|
|
513
|
+
return self.make_response(item, {"matches": found})
|
|
514
|
+
|
|
515
|
+
# ---------------------- Commands: Control ----------------------
|
|
516
|
+
|
|
517
|
+
def cmd_tuya_device_set(self, item: dict) -> dict:
|
|
518
|
+
p = item.get("params", {})
|
|
519
|
+
device_id = p.get("device_id")
|
|
520
|
+
code = p.get("code")
|
|
521
|
+
value = p.get("value")
|
|
522
|
+
codes_map = p.get("codes")
|
|
523
|
+
if not device_id:
|
|
524
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
525
|
+
|
|
526
|
+
if codes_map and isinstance(codes_map, dict):
|
|
527
|
+
cmds = self._build_commands(codes_map)
|
|
528
|
+
elif code is not None:
|
|
529
|
+
cmds = [{"code": code, "value": value}]
|
|
530
|
+
else:
|
|
531
|
+
return self.make_response(item, "Provide 'code'+'value' or 'codes' dict")
|
|
532
|
+
|
|
533
|
+
res = self._device_send_commands(device_id, cmds)
|
|
534
|
+
return self.make_response(item, res)
|
|
535
|
+
|
|
536
|
+
def cmd_tuya_device_send(self, item: dict) -> dict:
|
|
537
|
+
p = item.get("params", {})
|
|
538
|
+
device_id = p.get("device_id")
|
|
539
|
+
commands = p.get("commands")
|
|
540
|
+
if not device_id:
|
|
541
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
542
|
+
if not isinstance(commands, list) or not commands:
|
|
543
|
+
return self.make_response(item, "Param 'commands' must be a non-empty list of {code,value}")
|
|
544
|
+
res = self._device_send_commands(device_id, commands)
|
|
545
|
+
return self.make_response(item, res)
|
|
546
|
+
|
|
547
|
+
def cmd_tuya_device_on(self, item: dict) -> dict:
|
|
548
|
+
p = item.get("params", {})
|
|
549
|
+
device_id = p.get("device_id")
|
|
550
|
+
code = p.get("code")
|
|
551
|
+
if not device_id:
|
|
552
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
553
|
+
code = code or self._guess_switch_code(device_id)
|
|
554
|
+
res = self._device_send_commands(device_id, [{"code": code, "value": True}])
|
|
555
|
+
return self.make_response(item, res)
|
|
556
|
+
|
|
557
|
+
def cmd_tuya_device_off(self, item: dict) -> dict:
|
|
558
|
+
p = item.get("params", {})
|
|
559
|
+
device_id = p.get("device_id")
|
|
560
|
+
code = p.get("code")
|
|
561
|
+
if not device_id:
|
|
562
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
563
|
+
code = code or self._guess_switch_code(device_id)
|
|
564
|
+
res = self._device_send_commands(device_id, [{"code": code, "value": False}])
|
|
565
|
+
return self.make_response(item, res)
|
|
566
|
+
|
|
567
|
+
def cmd_tuya_device_toggle(self, item: dict) -> dict:
|
|
568
|
+
p = item.get("params", {})
|
|
569
|
+
device_id = p.get("device_id")
|
|
570
|
+
code = p.get("code")
|
|
571
|
+
if not device_id:
|
|
572
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
573
|
+
code, current = self._toggle_state(device_id, code=code)
|
|
574
|
+
if current is None:
|
|
575
|
+
return self.make_response(item, f"Unable to determine current state for device '{device_id}'. Provide 'code' or use tuya_device_on/off.")
|
|
576
|
+
res = self._device_send_commands(device_id, [{"code": code, "value": not current}])
|
|
577
|
+
return self.make_response(item, {"toggled": True, "code": code, "from": current, "to": not current, "result": res})
|
|
578
|
+
|
|
579
|
+
# ---------------------- Commands: Sensors ----------------------
|
|
580
|
+
|
|
581
|
+
def cmd_tuya_sensors_read(self, item: dict) -> dict:
|
|
582
|
+
p = item.get("params", {})
|
|
583
|
+
device_id = p.get("device_id")
|
|
584
|
+
if not device_id:
|
|
585
|
+
return self.make_response(item, "Param 'device_id' required")
|
|
586
|
+
status = self._device_status(device_id)
|
|
587
|
+
norm = self._normalize_sensors(status)
|
|
588
|
+
return self.make_response(item, norm)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# ================================================== #
|
|
4
|
+
# This file is a part of PYGPT package #
|
|
5
|
+
# Website: https://pygpt.net #
|
|
6
|
+
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
+
# MIT License #
|
|
8
|
+
# Created By : Marcin Szczygliński #
|
|
9
|
+
# Updated Date: 2025.08.27 20:00:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
from .plugin import *
|