@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.
@@ -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."