@bldgblocks/node-red-contrib-quietcool 0.1.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.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/nodes/quietcool-config.html +144 -0
- package/nodes/quietcool-config.js +300 -0
- package/nodes/quietcool-control.html +106 -0
- package/nodes/quietcool-control.js +128 -0
- package/nodes/quietcool-sensor.html +85 -0
- package/nodes/quietcool-sensor.js +101 -0
- package/package.json +47 -0
- package/python/bridge.py +606 -0
- package/python/requirements.txt +1 -0
- package/python/setup_venv.sh +43 -0
package/python/bridge.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
QuietCool BLE Bridge for Node-RED
|
|
4
|
+
|
|
5
|
+
Long-running daemon that maintains a BLE connection to a QuietCool fan
|
|
6
|
+
and accepts JSON commands on stdin, returning JSON responses on stdout.
|
|
7
|
+
|
|
8
|
+
Protocol (one JSON object per line):
|
|
9
|
+
stdin -> {"id":"<msg_id>","cmd":"<command>","args":{...}}
|
|
10
|
+
stdout <- {"id":"<msg_id>","ok":true,"data":{...}}
|
|
11
|
+
stdout <- {"id":"<msg_id>","ok":false,"error":"<message>"}
|
|
12
|
+
stdout <- {"type":"status","connected":true/false,"address":"..."}
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
connect - Connect and login to fan
|
|
16
|
+
disconnect - Disconnect from fan
|
|
17
|
+
get_status - Full status (info, state, params, version, presets)
|
|
18
|
+
get_state - Current work state (mode, range, temp, humidity)
|
|
19
|
+
get_info - Fan info (name, model, serial)
|
|
20
|
+
get_version - Firmware version
|
|
21
|
+
get_params - Current parameters/thresholds
|
|
22
|
+
get_presets - List preset profiles
|
|
23
|
+
get_remain - Timer remaining time
|
|
24
|
+
set_mode - Set mode: args.mode = "Idle"|"Timer"|"TH"
|
|
25
|
+
set_speed - Manual run: args.speed = "LOW"|"HIGH"
|
|
26
|
+
set_timer - Timer mode: args.hours, args.minutes, args.speed
|
|
27
|
+
set_preset - Apply preset: args.name = "Summer"|"Winter"|...
|
|
28
|
+
set_thresholds - Set temp/humidity thresholds
|
|
29
|
+
pair - Pair with fan (fan must be in pairing mode)
|
|
30
|
+
raw - Send raw API command: args.api, args.params
|
|
31
|
+
scan - Scan for QuietCool fans on BLE
|
|
32
|
+
generate_id - Generate a new random Phone ID
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import asyncio
|
|
36
|
+
import json
|
|
37
|
+
import sys
|
|
38
|
+
import os
|
|
39
|
+
import signal
|
|
40
|
+
import logging
|
|
41
|
+
import secrets
|
|
42
|
+
from typing import Optional, Dict, Any
|
|
43
|
+
|
|
44
|
+
# Add parent paths for imports
|
|
45
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
46
|
+
|
|
47
|
+
from bleak import BleakClient, BleakScanner
|
|
48
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger("quietcool-bridge")
|
|
51
|
+
|
|
52
|
+
# BLE constants
|
|
53
|
+
SERVICE_UUID = "000000ff-0000-1000-8000-00805f9b34fb"
|
|
54
|
+
CHAR_UUID = "0000ff01-0000-1000-8000-00805f9b34fb"
|
|
55
|
+
DEFAULT_CHUNK_SIZE = 20
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class FanBridge:
|
|
59
|
+
"""BLE bridge for a single QuietCool fan."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, address: str, phone_id: str):
|
|
62
|
+
self.address = address
|
|
63
|
+
self.phone_id = phone_id
|
|
64
|
+
self.client: Optional[BleakClient] = None
|
|
65
|
+
self._response_buffer = bytearray()
|
|
66
|
+
self._response_event = asyncio.Event()
|
|
67
|
+
self._response_json: Optional[Dict] = None
|
|
68
|
+
self._logged_in = False
|
|
69
|
+
self._presets_cache = None
|
|
70
|
+
self._chunk_size = DEFAULT_CHUNK_SIZE
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def is_connected(self) -> bool:
|
|
74
|
+
return self.client is not None and self.client.is_connected
|
|
75
|
+
|
|
76
|
+
def _on_disconnect(self, client: BleakClient):
|
|
77
|
+
self._logged_in = False
|
|
78
|
+
emit_status(False, self.address, "disconnected")
|
|
79
|
+
|
|
80
|
+
def _notification_handler(self, sender: BleakGATTCharacteristic, data: bytearray):
|
|
81
|
+
self._response_buffer.extend(data)
|
|
82
|
+
try:
|
|
83
|
+
text = self._response_buffer.decode("ascii")
|
|
84
|
+
if text.strip().endswith("}"):
|
|
85
|
+
try:
|
|
86
|
+
parsed = json.loads(text)
|
|
87
|
+
self._response_json = parsed
|
|
88
|
+
self._response_event.set()
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
pass
|
|
91
|
+
except UnicodeDecodeError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
async def connect(self) -> Dict:
|
|
95
|
+
if self.is_connected:
|
|
96
|
+
return {"already_connected": True}
|
|
97
|
+
|
|
98
|
+
self.client = BleakClient(
|
|
99
|
+
self.address,
|
|
100
|
+
timeout=15.0,
|
|
101
|
+
disconnected_callback=self._on_disconnect,
|
|
102
|
+
)
|
|
103
|
+
await self.client.connect()
|
|
104
|
+
|
|
105
|
+
if not self.client.is_connected:
|
|
106
|
+
raise ConnectionError("Failed to connect")
|
|
107
|
+
|
|
108
|
+
await self.client.start_notify(CHAR_UUID, self._notification_handler)
|
|
109
|
+
|
|
110
|
+
response = await self._send("Login", PhoneID=self.phone_id)
|
|
111
|
+
if not response or response.get("Result") != "Success":
|
|
112
|
+
await self.client.disconnect()
|
|
113
|
+
raise ConnectionError(
|
|
114
|
+
f"Login failed: {response}. Check PhoneID or pair the device."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
self._logged_in = True
|
|
118
|
+
emit_status(True, self.address, "connected")
|
|
119
|
+
return {
|
|
120
|
+
"connected": True,
|
|
121
|
+
"pair_state": response.get("PairState"),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async def disconnect(self) -> Dict:
|
|
125
|
+
if self.client and self.client.is_connected:
|
|
126
|
+
await self.client.disconnect()
|
|
127
|
+
self._logged_in = False
|
|
128
|
+
return {"disconnected": True}
|
|
129
|
+
|
|
130
|
+
async def ensure_connected(self):
|
|
131
|
+
if not self.is_connected:
|
|
132
|
+
await self.connect()
|
|
133
|
+
|
|
134
|
+
async def _send(self, api: str, timeout: float = 5.0, **kwargs) -> Optional[Dict]:
|
|
135
|
+
if not self.client or not self.client.is_connected:
|
|
136
|
+
raise ConnectionError("Not connected")
|
|
137
|
+
|
|
138
|
+
cmd = {"Api": api}
|
|
139
|
+
cmd.update(kwargs)
|
|
140
|
+
payload = json.dumps(cmd, separators=(",", ":"))
|
|
141
|
+
|
|
142
|
+
self._response_buffer.clear()
|
|
143
|
+
self._response_json = None
|
|
144
|
+
self._response_event.clear()
|
|
145
|
+
|
|
146
|
+
data = payload.encode("ascii")
|
|
147
|
+
for i in range(0, len(data), self._chunk_size):
|
|
148
|
+
chunk = data[i : i + self._chunk_size]
|
|
149
|
+
await self.client.write_gatt_char(CHAR_UUID, chunk, response=True)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
await asyncio.wait_for(self._response_event.wait(), timeout=timeout)
|
|
153
|
+
return self._response_json
|
|
154
|
+
except asyncio.TimeoutError:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
async def reconnect_and_send(self, api: str, timeout: float = 5.0, **kwargs):
|
|
158
|
+
"""Send command with auto-reconnect on BLE drop."""
|
|
159
|
+
try:
|
|
160
|
+
await self.ensure_connected()
|
|
161
|
+
return await self._send(api, timeout, **kwargs)
|
|
162
|
+
except (ConnectionError, Exception) as e:
|
|
163
|
+
logger.warning(f"Connection lost, reconnecting: {e}")
|
|
164
|
+
await asyncio.sleep(3)
|
|
165
|
+
await self.connect()
|
|
166
|
+
return await self._send(api, timeout, **kwargs)
|
|
167
|
+
|
|
168
|
+
# ---- High-level commands ----
|
|
169
|
+
|
|
170
|
+
async def get_state(self) -> Dict:
|
|
171
|
+
r = await self.reconnect_and_send("GetWorkState")
|
|
172
|
+
if r:
|
|
173
|
+
r["temperature_f"] = r.get("Temp_Sample", 0) / 10.0
|
|
174
|
+
r["humidity_pct"] = r.get("Humidity_Sample", 0)
|
|
175
|
+
return r or {}
|
|
176
|
+
|
|
177
|
+
async def get_info(self) -> Dict:
|
|
178
|
+
return await self.reconnect_and_send("GetFanInfo") or {}
|
|
179
|
+
|
|
180
|
+
async def get_version(self) -> Dict:
|
|
181
|
+
return await self.reconnect_and_send("GetVersion") or {}
|
|
182
|
+
|
|
183
|
+
async def get_params(self) -> Dict:
|
|
184
|
+
return await self.reconnect_and_send("GetParameter") or {}
|
|
185
|
+
|
|
186
|
+
async def get_presets(self) -> Dict:
|
|
187
|
+
r = await self.reconnect_and_send("GetPresets", FanType="THREE")
|
|
188
|
+
if r and "Presets" in r:
|
|
189
|
+
self._presets_cache = r["Presets"]
|
|
190
|
+
# Convert to named dicts for clarity
|
|
191
|
+
r["presets_named"] = []
|
|
192
|
+
for p in r["Presets"]:
|
|
193
|
+
r["presets_named"].append({
|
|
194
|
+
"name": p[0],
|
|
195
|
+
"temp_high": p[1],
|
|
196
|
+
"temp_med": p[2],
|
|
197
|
+
"temp_low": p[3],
|
|
198
|
+
"hum_high": p[4],
|
|
199
|
+
"hum_low": p[5],
|
|
200
|
+
"hum_range": p[6],
|
|
201
|
+
})
|
|
202
|
+
return r or {}
|
|
203
|
+
|
|
204
|
+
async def get_remain(self) -> Dict:
|
|
205
|
+
return await self.reconnect_and_send("GetRemainTime") or {}
|
|
206
|
+
|
|
207
|
+
async def get_status(self) -> Dict:
|
|
208
|
+
info = await self.get_info()
|
|
209
|
+
state = await self.get_state()
|
|
210
|
+
params = await self.get_params()
|
|
211
|
+
version = await self.get_version()
|
|
212
|
+
presets = await self.get_presets()
|
|
213
|
+
|
|
214
|
+
status = {
|
|
215
|
+
"connected": True,
|
|
216
|
+
"name": info.get("Name"),
|
|
217
|
+
"model": info.get("Model"),
|
|
218
|
+
"serial": info.get("SerialNum"),
|
|
219
|
+
"firmware": version.get("Version"),
|
|
220
|
+
"hw_version": version.get("HW_Version"),
|
|
221
|
+
"mode": state.get("Mode"),
|
|
222
|
+
"range": state.get("Range"),
|
|
223
|
+
"sensor_state": state.get("SensorState"),
|
|
224
|
+
"temperature_f": state.get("temperature_f"),
|
|
225
|
+
"humidity": state.get("humidity_pct"),
|
|
226
|
+
"fan_type": params.get("FanType"),
|
|
227
|
+
"presets": [p[0] for p in (self._presets_cache or [])],
|
|
228
|
+
"active_thresholds": {
|
|
229
|
+
"temp_high": params.get("GetTemp_H"),
|
|
230
|
+
"temp_med": params.get("GetTemp_M"),
|
|
231
|
+
"temp_low": params.get("GetTemp_L"),
|
|
232
|
+
"hum_high": params.get("GetHum_H"),
|
|
233
|
+
"hum_low": params.get("GetHum_L"),
|
|
234
|
+
"hum_range": params.get("GetHum_Range"),
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
# Match active preset
|
|
239
|
+
for p in self._presets_cache or []:
|
|
240
|
+
if (
|
|
241
|
+
p[1] == params.get("GetTemp_H")
|
|
242
|
+
and p[3] == params.get("GetTemp_L")
|
|
243
|
+
and p[4] == params.get("GetHum_H")
|
|
244
|
+
):
|
|
245
|
+
status["active_preset"] = p[0]
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
if state.get("Mode") == "Timer":
|
|
249
|
+
remain = await self.get_remain()
|
|
250
|
+
status["remain_hours"] = remain.get("RemainHour")
|
|
251
|
+
status["remain_minutes"] = remain.get("RemainMinute")
|
|
252
|
+
status["remain_seconds"] = remain.get("RemainSecond")
|
|
253
|
+
|
|
254
|
+
return status
|
|
255
|
+
|
|
256
|
+
async def set_mode(self, mode: str) -> Dict:
|
|
257
|
+
return await self.reconnect_and_send("SetMode", Mode=mode) or {}
|
|
258
|
+
|
|
259
|
+
async def set_speed(self, speed: str) -> Dict:
|
|
260
|
+
"""Manual/continuous run. BLE disconnects after this."""
|
|
261
|
+
r = await self.reconnect_and_send("SetSpeed", Speed=speed)
|
|
262
|
+
return r or {}
|
|
263
|
+
|
|
264
|
+
async def set_timer(self, hours: int, minutes: int, speed: str = "HIGH") -> Dict:
|
|
265
|
+
r = await self.reconnect_and_send(
|
|
266
|
+
"SetTime", SetHour=hours, SetMinute=minutes, SetTime_Range=speed
|
|
267
|
+
)
|
|
268
|
+
if r and r.get("Flag") == "TRUE":
|
|
269
|
+
await asyncio.sleep(0.5)
|
|
270
|
+
r2 = await self.reconnect_and_send("SetMode", Mode="Timer")
|
|
271
|
+
return r2 or r
|
|
272
|
+
return r or {}
|
|
273
|
+
|
|
274
|
+
async def set_preset(self, name: str) -> Dict:
|
|
275
|
+
if not self._presets_cache:
|
|
276
|
+
await self.get_presets()
|
|
277
|
+
if not self._presets_cache:
|
|
278
|
+
return {"error": "No presets available"}
|
|
279
|
+
|
|
280
|
+
for idx, p in enumerate(self._presets_cache):
|
|
281
|
+
if p[0].lower() == name.lower():
|
|
282
|
+
r = await self.reconnect_and_send(
|
|
283
|
+
"SetTempHumidity",
|
|
284
|
+
SetTemp_H=p[1],
|
|
285
|
+
SetTemp_M=p[2],
|
|
286
|
+
SetTemp_L=p[3],
|
|
287
|
+
SetHum_H=p[4],
|
|
288
|
+
SetHum_L=p[5],
|
|
289
|
+
SetHum_Range=p[6],
|
|
290
|
+
Index=idx,
|
|
291
|
+
)
|
|
292
|
+
if r and r.get("Flag") == "TRUE":
|
|
293
|
+
await asyncio.sleep(0.5)
|
|
294
|
+
await self.reconnect_and_send("SetMode", Mode="TH")
|
|
295
|
+
return r or {}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
"error": f"Preset '{name}' not found",
|
|
299
|
+
"available": [p[0] for p in self._presets_cache],
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async def set_thresholds(self, args: Dict) -> Dict:
|
|
303
|
+
return (
|
|
304
|
+
await self.reconnect_and_send(
|
|
305
|
+
"SetTempHumidity",
|
|
306
|
+
SetTemp_H=args.get("temp_high", 255),
|
|
307
|
+
SetTemp_M=args.get("temp_med", 255),
|
|
308
|
+
SetTemp_L=args.get("temp_low", 45),
|
|
309
|
+
SetHum_H=args.get("hum_high", 75),
|
|
310
|
+
SetHum_L=args.get("hum_low", 50),
|
|
311
|
+
SetHum_Range=args.get("hum_range", "LOW"),
|
|
312
|
+
Index=args.get("index", 0),
|
|
313
|
+
)
|
|
314
|
+
or {}
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
async def pair(self) -> Dict:
|
|
318
|
+
"""Pair with fan. Fan must be in pairing mode (hold pair button).
|
|
319
|
+
|
|
320
|
+
Flow (from emerose/quietcool):
|
|
321
|
+
1. Send Login with our PhoneID
|
|
322
|
+
2. If Result=Success → already paired
|
|
323
|
+
3. If Result=Fail and PairState indicates pairing mode → send Pair command
|
|
324
|
+
4. If Result=Fail and not in pairing mode → tell user to press button
|
|
325
|
+
"""
|
|
326
|
+
if not self.client or not self.client.is_connected:
|
|
327
|
+
raise ConnectionError("Not connected (call connect_for_pairing first)")
|
|
328
|
+
|
|
329
|
+
# Step 1: Try login
|
|
330
|
+
r = await self._send("Login", PhoneID=self.phone_id)
|
|
331
|
+
if not r:
|
|
332
|
+
return {"paired": False, "error": "No response from fan"}
|
|
333
|
+
|
|
334
|
+
if r.get("Result") == "Success":
|
|
335
|
+
self._logged_in = True
|
|
336
|
+
return {"paired": True, "phone_id": self.phone_id, "message": "Already paired with this ID"}
|
|
337
|
+
|
|
338
|
+
# Result is Fail — check if fan is in pairing mode
|
|
339
|
+
pair_state = r.get("PairState", "")
|
|
340
|
+
if pair_state.lower() in ("no", "nopaired", ""):
|
|
341
|
+
return {
|
|
342
|
+
"paired": False,
|
|
343
|
+
"message": "Fan is not in pairing mode. Hold the Pair button on the controller for ~5 seconds until the LED blinks, then try again.",
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
# Fan is in pairing mode — send Pair command
|
|
347
|
+
pair_r = await self._send("Pair", PhoneID=self.phone_id)
|
|
348
|
+
if pair_r and pair_r.get("Result") == "Success":
|
|
349
|
+
self._logged_in = True
|
|
350
|
+
emit_status(True, self.address, "paired")
|
|
351
|
+
return {"paired": True, "phone_id": self.phone_id, "message": "Pairing successful! Save this Phone ID."}
|
|
352
|
+
|
|
353
|
+
return {"paired": False, "error": f"Pair command failed: {pair_r}"}
|
|
354
|
+
|
|
355
|
+
async def connect_for_pairing(self) -> Dict:
|
|
356
|
+
"""Connect to fan without login (for pairing flow)."""
|
|
357
|
+
if self.is_connected:
|
|
358
|
+
return {"already_connected": True}
|
|
359
|
+
|
|
360
|
+
self.client = BleakClient(
|
|
361
|
+
self.address,
|
|
362
|
+
timeout=15.0,
|
|
363
|
+
disconnected_callback=self._on_disconnect,
|
|
364
|
+
)
|
|
365
|
+
await self.client.connect()
|
|
366
|
+
|
|
367
|
+
if not self.client.is_connected:
|
|
368
|
+
raise ConnectionError("Failed to connect")
|
|
369
|
+
|
|
370
|
+
await self.client.start_notify(CHAR_UUID, self._notification_handler)
|
|
371
|
+
return {"connected": True, "ready_to_pair": True}
|
|
372
|
+
|
|
373
|
+
async def raw(self, api: str, params: Dict) -> Dict:
|
|
374
|
+
return await self.reconnect_and_send(api, **params) or {}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ---- Globals ----
|
|
378
|
+
fan: Optional[FanBridge] = None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def emit(msg_id: str, ok: bool, data: Any = None, error: str = None):
|
|
382
|
+
"""Write a JSON response to stdout."""
|
|
383
|
+
resp = {"id": msg_id, "ok": ok}
|
|
384
|
+
if data is not None:
|
|
385
|
+
resp["data"] = data
|
|
386
|
+
if error is not None:
|
|
387
|
+
resp["error"] = error
|
|
388
|
+
sys.stdout.write(json.dumps(resp) + "\n")
|
|
389
|
+
sys.stdout.flush()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def emit_status(connected: bool, address: str = "", detail: str = ""):
|
|
393
|
+
"""Write a status update to stdout."""
|
|
394
|
+
msg = {"type": "status", "connected": connected, "address": address, "detail": detail}
|
|
395
|
+
sys.stdout.write(json.dumps(msg) + "\n")
|
|
396
|
+
sys.stdout.flush()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
async def handle_command(line: str):
|
|
400
|
+
global fan
|
|
401
|
+
|
|
402
|
+
try:
|
|
403
|
+
msg = json.loads(line)
|
|
404
|
+
except json.JSONDecodeError:
|
|
405
|
+
emit("?", False, error=f"Invalid JSON: {line}")
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
msg_id = msg.get("id", "?")
|
|
409
|
+
cmd = msg.get("cmd", "")
|
|
410
|
+
args = msg.get("args", {})
|
|
411
|
+
|
|
412
|
+
try:
|
|
413
|
+
if cmd == "connect":
|
|
414
|
+
address = args.get("address", "")
|
|
415
|
+
phone_id = args.get("phone_id", "")
|
|
416
|
+
if not address or not phone_id:
|
|
417
|
+
emit(msg_id, False, error="address and phone_id are required")
|
|
418
|
+
return
|
|
419
|
+
fan = FanBridge(address, phone_id)
|
|
420
|
+
result = await fan.connect()
|
|
421
|
+
emit(msg_id, True, result)
|
|
422
|
+
|
|
423
|
+
elif cmd == "disconnect":
|
|
424
|
+
if fan:
|
|
425
|
+
result = await fan.disconnect()
|
|
426
|
+
emit(msg_id, True, result)
|
|
427
|
+
else:
|
|
428
|
+
emit(msg_id, True, {"disconnected": True})
|
|
429
|
+
|
|
430
|
+
elif cmd == "get_status":
|
|
431
|
+
await _ensure_fan(msg_id)
|
|
432
|
+
result = await fan.get_status()
|
|
433
|
+
emit(msg_id, True, result)
|
|
434
|
+
|
|
435
|
+
elif cmd == "get_state":
|
|
436
|
+
await _ensure_fan(msg_id)
|
|
437
|
+
result = await fan.get_state()
|
|
438
|
+
emit(msg_id, True, result)
|
|
439
|
+
|
|
440
|
+
elif cmd == "get_info":
|
|
441
|
+
await _ensure_fan(msg_id)
|
|
442
|
+
result = await fan.get_info()
|
|
443
|
+
emit(msg_id, True, result)
|
|
444
|
+
|
|
445
|
+
elif cmd == "get_version":
|
|
446
|
+
await _ensure_fan(msg_id)
|
|
447
|
+
result = await fan.get_version()
|
|
448
|
+
emit(msg_id, True, result)
|
|
449
|
+
|
|
450
|
+
elif cmd == "get_params":
|
|
451
|
+
await _ensure_fan(msg_id)
|
|
452
|
+
result = await fan.get_params()
|
|
453
|
+
emit(msg_id, True, result)
|
|
454
|
+
|
|
455
|
+
elif cmd == "get_presets":
|
|
456
|
+
await _ensure_fan(msg_id)
|
|
457
|
+
result = await fan.get_presets()
|
|
458
|
+
emit(msg_id, True, result)
|
|
459
|
+
|
|
460
|
+
elif cmd == "get_remain":
|
|
461
|
+
await _ensure_fan(msg_id)
|
|
462
|
+
result = await fan.get_remain()
|
|
463
|
+
emit(msg_id, True, result)
|
|
464
|
+
|
|
465
|
+
elif cmd == "set_mode":
|
|
466
|
+
await _ensure_fan(msg_id)
|
|
467
|
+
mode = args.get("mode", "Idle")
|
|
468
|
+
result = await fan.set_mode(mode)
|
|
469
|
+
emit(msg_id, True, result)
|
|
470
|
+
|
|
471
|
+
elif cmd == "set_speed":
|
|
472
|
+
await _ensure_fan(msg_id)
|
|
473
|
+
speed = args.get("speed", "HIGH")
|
|
474
|
+
result = await fan.set_speed(speed)
|
|
475
|
+
emit(msg_id, True, result)
|
|
476
|
+
|
|
477
|
+
elif cmd == "set_timer":
|
|
478
|
+
await _ensure_fan(msg_id)
|
|
479
|
+
result = await fan.set_timer(
|
|
480
|
+
hours=args.get("hours", 1),
|
|
481
|
+
minutes=args.get("minutes", 0),
|
|
482
|
+
speed=args.get("speed", "HIGH"),
|
|
483
|
+
)
|
|
484
|
+
emit(msg_id, True, result)
|
|
485
|
+
|
|
486
|
+
elif cmd == "set_preset":
|
|
487
|
+
await _ensure_fan(msg_id)
|
|
488
|
+
name = args.get("name", "")
|
|
489
|
+
if not name:
|
|
490
|
+
emit(msg_id, False, error="preset name required")
|
|
491
|
+
return
|
|
492
|
+
result = await fan.set_preset(name)
|
|
493
|
+
if "error" in result:
|
|
494
|
+
emit(msg_id, False, error=result["error"], data=result)
|
|
495
|
+
else:
|
|
496
|
+
emit(msg_id, True, result)
|
|
497
|
+
|
|
498
|
+
elif cmd == "set_thresholds":
|
|
499
|
+
await _ensure_fan(msg_id)
|
|
500
|
+
result = await fan.set_thresholds(args)
|
|
501
|
+
emit(msg_id, True, result)
|
|
502
|
+
|
|
503
|
+
elif cmd == "pair":
|
|
504
|
+
# Pairing flow: connect without login, then attempt pair
|
|
505
|
+
address = args.get("address", "")
|
|
506
|
+
phone_id = args.get("phone_id", "")
|
|
507
|
+
if not address:
|
|
508
|
+
emit(msg_id, False, error="address is required")
|
|
509
|
+
return
|
|
510
|
+
if not phone_id:
|
|
511
|
+
phone_id = secrets.token_hex(8) # Auto-generate
|
|
512
|
+
fan = FanBridge(address, phone_id)
|
|
513
|
+
await fan.connect_for_pairing()
|
|
514
|
+
result = await fan.pair()
|
|
515
|
+
emit(msg_id, True, result)
|
|
516
|
+
|
|
517
|
+
elif cmd == "scan":
|
|
518
|
+
# Scan for QuietCool fans (bleak 2.x returns (device, adv_data) tuples)
|
|
519
|
+
timeout = args.get("timeout", 8)
|
|
520
|
+
discovered = await BleakScanner.discover(timeout=timeout, return_adv=True)
|
|
521
|
+
fans = []
|
|
522
|
+
for address, (device, adv_data) in discovered.items():
|
|
523
|
+
name = device.name or adv_data.local_name or ""
|
|
524
|
+
mfr_data = adv_data.manufacturer_data or {}
|
|
525
|
+
# QuietCool fans advertise as ATTICFAN_* with manufacturer ID 0x4133 (16691)
|
|
526
|
+
is_quietcool = name.startswith("ATTICFAN") or (16691 in mfr_data)
|
|
527
|
+
if is_quietcool:
|
|
528
|
+
fans.append({
|
|
529
|
+
"address": device.address,
|
|
530
|
+
"name": name,
|
|
531
|
+
"rssi": adv_data.rssi,
|
|
532
|
+
})
|
|
533
|
+
emit(msg_id, True, {"fans": fans})
|
|
534
|
+
|
|
535
|
+
elif cmd == "generate_id":
|
|
536
|
+
new_id = secrets.token_hex(8)
|
|
537
|
+
emit(msg_id, True, {"phone_id": new_id})
|
|
538
|
+
|
|
539
|
+
elif cmd == "raw":
|
|
540
|
+
await _ensure_fan(msg_id)
|
|
541
|
+
api = args.get("api", "")
|
|
542
|
+
params = args.get("params", {})
|
|
543
|
+
if not api:
|
|
544
|
+
emit(msg_id, False, error="api name required")
|
|
545
|
+
return
|
|
546
|
+
result = await fan.raw(api, params)
|
|
547
|
+
emit(msg_id, True, result)
|
|
548
|
+
|
|
549
|
+
elif cmd == "ping":
|
|
550
|
+
emit(msg_id, True, {"pong": True, "connected": fan.is_connected if fan else False})
|
|
551
|
+
|
|
552
|
+
else:
|
|
553
|
+
emit(msg_id, False, error=f"Unknown command: {cmd}")
|
|
554
|
+
|
|
555
|
+
except Exception as e:
|
|
556
|
+
logger.exception(f"Error handling {cmd}")
|
|
557
|
+
emit(msg_id, False, error=str(e))
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
async def _ensure_fan(msg_id: str):
|
|
561
|
+
global fan
|
|
562
|
+
if not fan:
|
|
563
|
+
raise ConnectionError("Not connected. Send 'connect' first.")
|
|
564
|
+
await fan.ensure_connected()
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
async def stdin_reader():
|
|
568
|
+
"""Read lines from stdin asynchronously."""
|
|
569
|
+
loop = asyncio.get_event_loop()
|
|
570
|
+
reader = asyncio.StreamReader()
|
|
571
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
572
|
+
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
|
573
|
+
|
|
574
|
+
emit_status(False, "", "bridge_ready")
|
|
575
|
+
|
|
576
|
+
while True:
|
|
577
|
+
line = await reader.readline()
|
|
578
|
+
if not line:
|
|
579
|
+
break # EOF - Node-RED process ended
|
|
580
|
+
try:
|
|
581
|
+
await handle_command(line.decode("utf-8").strip())
|
|
582
|
+
except Exception as e:
|
|
583
|
+
logger.exception(f"Unhandled error: {e}")
|
|
584
|
+
emit("?", False, error=f"Internal error: {e}")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def main():
|
|
588
|
+
log_level = os.environ.get("QUIETCOOL_LOG_LEVEL", "WARNING").upper()
|
|
589
|
+
logging.basicConfig(
|
|
590
|
+
level=getattr(logging, log_level, logging.WARNING),
|
|
591
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
592
|
+
stream=sys.stderr, # Logs go to stderr, JSON protocol goes to stdout
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
logger.info("QuietCool BLE Bridge starting...")
|
|
596
|
+
|
|
597
|
+
try:
|
|
598
|
+
asyncio.run(stdin_reader())
|
|
599
|
+
except KeyboardInterrupt:
|
|
600
|
+
pass
|
|
601
|
+
finally:
|
|
602
|
+
logger.info("Bridge shutting down")
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
if __name__ == "__main__":
|
|
606
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bleak>=0.21.0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Setup Python virtual environment for the QuietCool BLE bridge
|
|
3
|
+
# This runs as npm postinstall
|
|
4
|
+
|
|
5
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
6
|
+
VENV_DIR="$SCRIPT_DIR/.venv"
|
|
7
|
+
|
|
8
|
+
echo "QuietCool BLE: Setting up Python environment..."
|
|
9
|
+
|
|
10
|
+
# Check for Python 3
|
|
11
|
+
PYTHON=""
|
|
12
|
+
for cmd in python3 python; do
|
|
13
|
+
if command -v "$cmd" &>/dev/null; then
|
|
14
|
+
version=$("$cmd" --version 2>&1 | grep -oP '\d+\.\d+')
|
|
15
|
+
major=$(echo "$version" | cut -d. -f1)
|
|
16
|
+
minor=$(echo "$version" | cut -d. -f2)
|
|
17
|
+
if [ "$major" -ge 3 ] && [ "$minor" -ge 9 ]; then
|
|
18
|
+
PYTHON="$cmd"
|
|
19
|
+
break
|
|
20
|
+
fi
|
|
21
|
+
fi
|
|
22
|
+
done
|
|
23
|
+
|
|
24
|
+
if [ -z "$PYTHON" ]; then
|
|
25
|
+
echo "ERROR: Python 3.9+ is required but not found."
|
|
26
|
+
echo "Install with: sudo apt-get install python3 python3-venv"
|
|
27
|
+
exit 1
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
echo "Using Python: $PYTHON ($($PYTHON --version))"
|
|
31
|
+
|
|
32
|
+
# Create venv if it doesn't exist
|
|
33
|
+
if [ ! -d "$VENV_DIR" ]; then
|
|
34
|
+
echo "Creating virtual environment..."
|
|
35
|
+
"$PYTHON" -m venv "$VENV_DIR"
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
# Install dependencies
|
|
39
|
+
echo "Installing BLE dependencies..."
|
|
40
|
+
"$VENV_DIR/bin/pip" install --quiet --upgrade pip
|
|
41
|
+
"$VENV_DIR/bin/pip" install --quiet "bleak>=0.21.0"
|
|
42
|
+
|
|
43
|
+
echo "QuietCool BLE: Setup complete."
|