philips-airctrl 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from philips_airctrl._version import __version__
2
+ from philips_airctrl.coap.client import Client as CoAPClient
3
+
4
+ __all__ = ["CoAPClient", "__version__"]
@@ -0,0 +1,4 @@
1
+ from philips_airctrl.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,3 @@
1
+ """Single source of truth for package version."""
2
+
3
+ __version__ = "1.0.0"
philips_airctrl/cli.py ADDED
@@ -0,0 +1,337 @@
1
+ """Command-line interface for philips-airctrl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import contextlib
8
+ import json
9
+ import logging
10
+ import sys
11
+
12
+ from tabulate import tabulate
13
+
14
+ from philips_airctrl import CoAPClient
15
+ from philips_airctrl.device_info import DeviceInfoExtractor
16
+ from philips_airctrl.discovery import DeviceDiscovery
17
+ from philips_airctrl.setup_wizard import SetupWizard
18
+
19
+ logging.basicConfig(level=logging.WARN)
20
+ logger = logging.getLogger(__package__)
21
+
22
+
23
+ def parse_args(args: list[str] | None = None) -> argparse.Namespace:
24
+ parser = argparse.ArgumentParser()
25
+ subparsers = parser.add_subparsers(
26
+ dest="command",
27
+ required=True,
28
+ help="sub-command help",
29
+ )
30
+
31
+ parser.add_argument(
32
+ "-D",
33
+ "--debug",
34
+ dest="debug",
35
+ action="store_true",
36
+ help="Enable debug output",
37
+ )
38
+ parser_status = subparsers.add_parser(
39
+ "status",
40
+ help="get status of device",
41
+ )
42
+ parser_status.add_argument(
43
+ "-H",
44
+ "--host",
45
+ dest="host",
46
+ type=str,
47
+ required=True,
48
+ help="Address of CoAP device",
49
+ )
50
+ parser_status.add_argument(
51
+ "-P",
52
+ "--port",
53
+ dest="port",
54
+ type=int,
55
+ default=5683,
56
+ help="Port of CoAP device (default: %(default)s)",
57
+ )
58
+ parser_status.add_argument(
59
+ "-J",
60
+ "--json",
61
+ dest="json",
62
+ action="store_true",
63
+ help="Output status as JSON",
64
+ )
65
+ parser_status_observe = subparsers.add_parser(
66
+ "status-observe",
67
+ help="Observe status of device",
68
+ )
69
+ parser_status_observe.add_argument(
70
+ "-H",
71
+ "--host",
72
+ dest="host",
73
+ type=str,
74
+ required=True,
75
+ help="Address of CoAP device",
76
+ )
77
+ parser_status_observe.add_argument(
78
+ "-P",
79
+ "--port",
80
+ dest="port",
81
+ type=int,
82
+ default=5683,
83
+ help="Port of CoAP device (default: %(default)s)",
84
+ )
85
+ parser_status_observe.add_argument(
86
+ "-J",
87
+ "--json",
88
+ dest="json",
89
+ action="store_true",
90
+ help="Output status as JSON",
91
+ )
92
+ parser_set = subparsers.add_parser(
93
+ "set",
94
+ help="Set value of device",
95
+ )
96
+ parser_set.add_argument(
97
+ "-H",
98
+ "--host",
99
+ dest="host",
100
+ type=str,
101
+ required=True,
102
+ help="Address of CoAP device",
103
+ )
104
+ parser_set.add_argument(
105
+ "-P",
106
+ "--port",
107
+ dest="port",
108
+ type=int,
109
+ default=5683,
110
+ help="Port of CoAP device (default: %(default)s)",
111
+ )
112
+ parser_set.add_argument(
113
+ "values",
114
+ metavar="K=V",
115
+ type=str,
116
+ nargs="+",
117
+ help="Key-Value pairs to set",
118
+ )
119
+ parser_set.add_argument(
120
+ "-I",
121
+ "--int",
122
+ dest="value_as_int",
123
+ action="store_true",
124
+ help="Encode value as integer",
125
+ )
126
+
127
+ # Discovery command
128
+ parser_discover = subparsers.add_parser(
129
+ "discover",
130
+ help="Discover Philips air purifiers on the network",
131
+ )
132
+ parser_discover.add_argument(
133
+ "-n",
134
+ "--network",
135
+ dest="network",
136
+ type=str,
137
+ help="Specific network to scan (e.g., 192.168.1.0/24)",
138
+ )
139
+ parser_discover.add_argument(
140
+ "-t",
141
+ "--timeout",
142
+ dest="timeout",
143
+ type=float,
144
+ default=5.0,
145
+ help="Timeout for device discovery (default: %(default)s)",
146
+ )
147
+
148
+ # Device info command
149
+ parser_device_info = subparsers.add_parser(
150
+ "device-info",
151
+ help="Get comprehensive device information",
152
+ )
153
+ parser_device_info.add_argument(
154
+ "-H",
155
+ "--host",
156
+ dest="host",
157
+ type=str,
158
+ required=True,
159
+ help="Address of CoAP device",
160
+ )
161
+ parser_device_info.add_argument(
162
+ "-P",
163
+ "--port",
164
+ dest="port",
165
+ type=int,
166
+ default=5683,
167
+ help="Port of CoAP device (default: %(default)s)",
168
+ )
169
+ parser_device_info.add_argument(
170
+ "-f",
171
+ "--format",
172
+ dest="format",
173
+ choices=["json", "yaml"],
174
+ default="json",
175
+ help="Output format (default: %(default)s)",
176
+ )
177
+ parser_device_info.add_argument(
178
+ "-o",
179
+ "--output",
180
+ dest="output",
181
+ type=str,
182
+ help="Output file (default: stdout)",
183
+ )
184
+
185
+ # Setup wizard command
186
+ subparsers.add_parser(
187
+ "setup",
188
+ help="Interactive setup wizard for Home Assistant integration",
189
+ )
190
+
191
+ return parser.parse_args(args)
192
+
193
+
194
+ async def handle_discover_command(args: argparse.Namespace) -> None:
195
+ """Handle the discover command."""
196
+ print("Discovering Philips air purifiers on the network...")
197
+ print()
198
+
199
+ discovery = DeviceDiscovery(timeout=args.timeout)
200
+
201
+ if args.network:
202
+ devices = await discovery.discover_single_network(args.network)
203
+ print(f"Scanning network: {args.network}")
204
+ else:
205
+ devices = await discovery.discover_devices()
206
+ networks = discovery.get_network_ranges()
207
+ print(f"Scanning networks: {', '.join(networks)}")
208
+
209
+ print()
210
+
211
+ if not devices:
212
+ print("No devices found.")
213
+ print()
214
+ print("Troubleshooting tips:")
215
+ print(" Ensure your air purifier is connected to the same network")
216
+ print(" Check that the device is powered on")
217
+ print(" Verify your firewall allows UDP traffic on port 5683")
218
+ print(" Try specifying a specific network with -n option")
219
+ return
220
+
221
+ print(f"Found {len(devices)} device(s):")
222
+ print()
223
+
224
+ headers = ["IP Address", "Model", "Name", "Firmware", "WiFi Signal"]
225
+ rows = []
226
+
227
+ for device in devices:
228
+ signal = f"{device.status.get('rssi', 'N/A')} dBm" if device.status else "N/A"
229
+ rows.append(
230
+ [
231
+ device.ip,
232
+ device.model or "Unknown",
233
+ device.name or "Unknown",
234
+ device.firmware_version or "Unknown",
235
+ signal,
236
+ ]
237
+ )
238
+
239
+ print(tabulate(rows, headers=headers, tablefmt="grid"))
240
+ print()
241
+ print("Use 'philips-airctrl device-info -H <IP>' to extract technical device data")
242
+ print("Use 'philips-airctrl setup' to collect device info for developers")
243
+
244
+
245
+ async def handle_device_info_command(args: argparse.Namespace) -> None:
246
+ """Handle the device-info command."""
247
+ print(f"Gathering device information from {args.host}...")
248
+
249
+ extractor = DeviceInfoExtractor(args.host, args.port)
250
+ device_info = await extractor.get_device_info()
251
+
252
+ if args.format == "json":
253
+ output = extractor.export_json(device_info, pretty=True)
254
+ else:
255
+ output = extractor.export_yaml(device_info)
256
+
257
+ if args.output:
258
+ with open(args.output, "w") as f:
259
+ f.write(output)
260
+ print(f"Device information saved to: {args.output}")
261
+ else:
262
+ print(output)
263
+
264
+
265
+ async def handle_setup_command() -> None:
266
+ """Handle the setup command."""
267
+ wizard = SetupWizard()
268
+ await wizard.run()
269
+
270
+
271
+ async def async_main() -> None:
272
+ args = parse_args()
273
+ if args.debug:
274
+ logger.setLevel(logging.DEBUG)
275
+ logging.getLogger("coap").setLevel(logging.DEBUG)
276
+ logging.getLogger("philips_airpurifier").setLevel(logging.DEBUG)
277
+
278
+ if args.command == "discover":
279
+ await handle_discover_command(args)
280
+ return
281
+ if args.command == "setup":
282
+ await handle_setup_command()
283
+ return
284
+
285
+ if not hasattr(args, "host") or not args.host:
286
+ print("Error: Host is required for this command")
287
+ return
288
+
289
+ client = None
290
+ try:
291
+ client = await CoAPClient.create(host=args.host, port=args.port)
292
+ if args.command == "status":
293
+ status, max_age = await client.get_status()
294
+ if args.json:
295
+ print(json.dumps(status, indent=2))
296
+ else:
297
+ print(status)
298
+ print(f"max_age = {max_age}")
299
+ elif args.command == "status-observe":
300
+ async for status in client.observe_status():
301
+ if args.json:
302
+ print(json.dumps(status, indent=2))
303
+ else:
304
+ print(status)
305
+ sys.stdout.flush()
306
+ elif args.command == "set":
307
+ data: dict[str, str | int | bool] = {}
308
+ failed = False
309
+ for e in args.values:
310
+ k, v = e.split("=")
311
+ if v == "true":
312
+ data[k] = True
313
+ elif v == "false":
314
+ data[k] = False
315
+ elif args.value_as_int:
316
+ try:
317
+ data[k] = int(v)
318
+ except ValueError:
319
+ print(f"Cannot encode value '{v}' as int")
320
+ failed = True
321
+ break
322
+ else:
323
+ data[k] = v
324
+ if not failed and data:
325
+ await client.set_control_values(data=data)
326
+ elif args.command == "device-info":
327
+ await handle_device_info_command(args)
328
+ except (KeyboardInterrupt, asyncio.CancelledError):
329
+ pass
330
+ finally:
331
+ if client:
332
+ await client.shutdown()
333
+
334
+
335
+ def main() -> None:
336
+ with contextlib.suppress(KeyboardInterrupt):
337
+ asyncio.run(async_main())
@@ -0,0 +1 @@
1
+ from philips_airctrl.coap.client import Client as Client
@@ -0,0 +1,167 @@
1
+ """Client to interact with the aiocoap library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import AsyncIterator
13
+
14
+ from aiocoap import Context, Message, Unreliable
15
+ from aiocoap.error import NetworkError
16
+ from aiocoap.numbers.codes import GET, POST
17
+
18
+ from philips_airctrl.coap.encryption import EncryptionContext
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ RETRY_DELAY = 0.5
23
+
24
+
25
+ class Client:
26
+ STATUS_PATH = "/sys/dev/status"
27
+ CONTROL_PATH = "/sys/dev/control"
28
+ SYNC_PATH = "/sys/dev/sync"
29
+
30
+ def __init__(self, host: str, port: int = 5683) -> None:
31
+ self.host = host
32
+ self.port = port
33
+ self._client_context: Context | None = None
34
+ self._encryption_context: EncryptionContext | None = None
35
+
36
+ async def _init(self) -> None:
37
+ self._client_context = await Context.create_client_context()
38
+ self._encryption_context = EncryptionContext()
39
+ await self._sync()
40
+
41
+ @classmethod
42
+ async def create(cls, *args: Any, **kwargs: Any) -> Client:
43
+ obj = cls(*args, **kwargs)
44
+ await obj._init()
45
+ return obj
46
+
47
+ async def __aenter__(self) -> Client:
48
+ await self._init()
49
+ return self
50
+
51
+ async def __aexit__(
52
+ self,
53
+ exc_type: type[BaseException] | None,
54
+ exc_val: BaseException | None,
55
+ exc_tb: Any,
56
+ ) -> None:
57
+ await self.shutdown()
58
+
59
+ async def shutdown(self) -> None:
60
+ if self._client_context:
61
+ await self._client_context.shutdown()
62
+
63
+ async def _sync(self) -> None:
64
+ logger.debug("syncing")
65
+ sync_request = os.urandom(4).hex().upper()
66
+ request = Message(
67
+ code=POST,
68
+ transport_tuning=Unreliable,
69
+ uri=f"coap://{self.host}:{self.port}{self.SYNC_PATH}",
70
+ payload=sync_request.encode(),
71
+ )
72
+ response = await self._client_context.request(request).response
73
+ client_key = response.payload.decode()
74
+ logger.debug("synced: %s", client_key)
75
+ self._encryption_context.set_client_key(client_key)
76
+
77
+ async def get_status(self) -> tuple[dict[str, Any], int]:
78
+ logger.debug("retrieving status")
79
+ request = Message(
80
+ code=GET,
81
+ transport_tuning=Unreliable,
82
+ uri=f"coap://{self.host}:{self.port}{self.STATUS_PATH}",
83
+ )
84
+ request.opt.observe = 0
85
+ try:
86
+ response = await self._client_context.request(request).response
87
+ except NetworkError:
88
+ logger.error("network error while retrieving status")
89
+ raise
90
+ payload_encrypted = response.payload.decode()
91
+ payload = self._encryption_context.decrypt(payload_encrypted)
92
+ logger.debug("status: %s", payload)
93
+ state_reported = json.loads(payload)
94
+ max_age = 60
95
+ try:
96
+ max_age = response.opt.max_age
97
+ logger.debug("max age = %s", max_age)
98
+ except AttributeError:
99
+ logger.debug("no max age found in CoAP options")
100
+ return state_reported["state"]["reported"], max_age
101
+
102
+ async def observe_status(self) -> AsyncIterator[dict[str, Any]]:
103
+ def decrypt_status(response: Any) -> dict[str, Any]:
104
+ payload_encrypted = response.payload.decode()
105
+ payload = self._encryption_context.decrypt(payload_encrypted)
106
+ logger.debug("observation status: %s", payload)
107
+ status = json.loads(payload)
108
+ return status["state"]["reported"]
109
+
110
+ logger.debug("observing status")
111
+ request = Message(
112
+ code=GET,
113
+ transport_tuning=Unreliable,
114
+ uri=f"coap://{self.host}:{self.port}{self.STATUS_PATH}",
115
+ )
116
+ request.opt.observe = 0
117
+ requester = self._client_context.request(request)
118
+ response = await requester.response
119
+ yield decrypt_status(response)
120
+ async for response in requester.observation:
121
+ yield decrypt_status(response)
122
+
123
+ async def set_control_value(
124
+ self, key: str, value: Any, retry_count: int = 5, resync: bool = True
125
+ ) -> bool | None:
126
+ return await self.set_control_values(
127
+ data={key: value}, retry_count=retry_count, resync=resync
128
+ )
129
+
130
+ async def set_control_values(
131
+ self, data: dict[str, Any], retry_count: int = 5, resync: bool = True
132
+ ) -> bool | None:
133
+ state_desired = {
134
+ "state": {
135
+ "desired": {
136
+ "CommandType": "app",
137
+ "DeviceId": "",
138
+ "EnduserId": "",
139
+ **data,
140
+ }
141
+ }
142
+ }
143
+ payload = json.dumps(state_desired)
144
+ logger.debug("REQUEST: %s", payload)
145
+ payload_encrypted = self._encryption_context.encrypt(payload)
146
+
147
+ for attempt in range(retry_count + 1):
148
+ request = Message(
149
+ code=POST,
150
+ transport_tuning=Unreliable,
151
+ uri=f"coap://{self.host}:{self.port}{self.CONTROL_PATH}",
152
+ payload=payload_encrypted.encode(),
153
+ )
154
+ response = await self._client_context.request(request).response
155
+ logger.debug("RESPONSE: %s", response.payload)
156
+ result = json.loads(response.payload)
157
+ if result.get("status") == "success":
158
+ return True
159
+ if resync:
160
+ logger.debug("set_control_value failed. resyncing...")
161
+ await self._sync()
162
+ if attempt < retry_count:
163
+ logger.debug("set_control_value failed. retrying...")
164
+ await asyncio.sleep(RETRY_DELAY)
165
+
166
+ logger.error("set_control_value failed: %s", data)
167
+ return False
@@ -0,0 +1,69 @@
1
+ """AES-CBC encryption/decryption for Philips air purifier CoAP protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import Any
7
+
8
+ from Cryptodome.Cipher import AES
9
+ from Cryptodome.Util.Padding import pad, unpad
10
+
11
+
12
+ class DigestMismatchError(Exception):
13
+ pass
14
+
15
+
16
+ class ClientKeyNotInitialisedError(RuntimeError):
17
+ """Raised when an operation requires a client key that has not been set."""
18
+
19
+ def __init__(self) -> None:
20
+ super().__init__("Client key not initialised; call set_client_key() first")
21
+
22
+
23
+ class EncryptionContext:
24
+ SECRET_KEY = "JiangPan" # noqa: S105 # nosec B105 - required by device protocol
25
+
26
+ def __init__(self) -> None:
27
+ self._client_key: str | None = None
28
+
29
+ def set_client_key(self, client_key: str) -> None:
30
+ self._client_key = client_key
31
+
32
+ def _increment_client_key(self) -> None:
33
+ if self._client_key is None:
34
+ raise ClientKeyNotInitialisedError
35
+ client_key_int = (int(self._client_key, 16) + 1) & 0xFFFFFFFF
36
+ client_key_next = client_key_int.to_bytes(4, byteorder="big").hex().upper()
37
+ self._client_key = client_key_next
38
+
39
+ def _create_cipher(self, key: str) -> Any:
40
+ key_and_iv = hashlib.md5((self.SECRET_KEY + key).encode()).hexdigest().upper() # noqa: S324 # nosec B324 - required by device protocol
41
+ half_keylen = len(key_and_iv) // 2
42
+ secret_key = key_and_iv[:half_keylen]
43
+ iv = key_and_iv[half_keylen:]
44
+ return AES.new(
45
+ key=secret_key.encode(),
46
+ mode=AES.MODE_CBC,
47
+ iv=iv.encode(),
48
+ )
49
+
50
+ def encrypt(self, payload: str) -> str:
51
+ self._increment_client_key()
52
+ key = self._client_key
53
+ plaintext_padded = pad(payload.encode(), 16, style="pkcs7")
54
+ cipher = self._create_cipher(key)
55
+ ciphertext = cipher.encrypt(plaintext_padded).hex().upper()
56
+ digest = hashlib.sha256((key + ciphertext).encode()).hexdigest().upper()
57
+ return key + ciphertext + digest
58
+
59
+ def decrypt(self, payload_encrypted: str) -> str:
60
+ key = payload_encrypted[:8]
61
+ ciphertext = payload_encrypted[8:-64]
62
+ digest = payload_encrypted[-64:]
63
+ digest_calculated = hashlib.sha256((key + ciphertext).encode()).hexdigest().upper()
64
+ if digest != digest_calculated:
65
+ raise DigestMismatchError
66
+ cipher = self._create_cipher(key)
67
+ plaintext_padded = cipher.decrypt(bytes.fromhex(ciphertext))
68
+ plaintext_unpadded = unpad(plaintext_padded, 16, style="pkcs7")
69
+ return plaintext_unpadded.decode()