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.
- philips_airctrl/__init__.py +4 -0
- philips_airctrl/__main__.py +4 -0
- philips_airctrl/_version.py +3 -0
- philips_airctrl/cli.py +337 -0
- philips_airctrl/coap/__init__.py +1 -0
- philips_airctrl/coap/client.py +167 -0
- philips_airctrl/coap/encryption.py +69 -0
- philips_airctrl/device_info.py +719 -0
- philips_airctrl/discovery.py +154 -0
- philips_airctrl/models.py +119 -0
- philips_airctrl/setup_wizard.py +315 -0
- philips_airctrl-1.0.0.dist-info/METADATA +358 -0
- philips_airctrl-1.0.0.dist-info/RECORD +16 -0
- philips_airctrl-1.0.0.dist-info/WHEEL +4 -0
- philips_airctrl-1.0.0.dist-info/entry_points.txt +2 -0
- philips_airctrl-1.0.0.dist-info/licenses/LICENSE +21 -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()
|