PySwitchbot 2.8.0__tar.gz → 2.9.1__tar.gz
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.
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/PKG-INFO +58 -6
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/README.md +56 -5
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/pyproject.toml +1 -1
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/__init__.py +14 -0
- pyswitchbot-2.9.1/switchbot/__version__.py +1 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/_sensor_th.py +1 -1
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/device.py +177 -11
- pyswitchbot-2.9.1/switchbot/oauth.py +183 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/utils.py +16 -0
- pyswitchbot-2.8.0/switchbot/__version__.py +0 -1
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/LICENSE +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parser.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/__init__.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/air_purifier.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/art_frame.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/blind_tilt.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/bot.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/bulb.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/ceiling_light.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/climate_panel.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/contact.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/curtain.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/fan.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/hub2.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/hub3.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/hubmini_matter.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/humidifier.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/keypad.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/keypad_vision.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/leak.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/light_strip.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/lock.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/meter.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/motion.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/plug.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/presence_sensor.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/relay_switch.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/remote.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/roller_shade.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/smart_thermostat_radiator.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/vacuum.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/adv_parsers/weather_station.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/api_config.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/__init__.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/air_purifier.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/climate.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/evaporative_humidifier.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/fan.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/hub2.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/hub3.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/light.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/lock.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/const/presence_sensor.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/__init__.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/air_purifier.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/art_frame.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/base_cover.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/base_light.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/blind_tilt.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/bot.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/bulb.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/ceiling_light.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/contact.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/curtain.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/evaporative_humidifier.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/fan.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/humidifier.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/keypad.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/keypad_vision.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/light_strip.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/lock.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/meter.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/meter_pro.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/motion.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/plug.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/relay_switch.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/roller_shade.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/smart_thermostat_radiator.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/universal_remote.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/devices/vacuum.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/discovery.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/enum.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/helpers.py +0 -0
- {pyswitchbot-2.8.0 → pyswitchbot-2.9.1}/switchbot/models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PySwitchbot
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.9.1
|
|
4
4
|
Summary: A library to communicate with Switchbot
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
20
21
|
Classifier: Topic :: Home Automation
|
|
21
22
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
23
|
Requires-Dist: aiohttp (>=3.9.5)
|
|
@@ -41,6 +42,57 @@ source .venv/bin/activate
|
|
|
41
42
|
pip install .
|
|
42
43
|
```
|
|
43
44
|
|
|
45
|
+
## OAuth account access
|
|
46
|
+
|
|
47
|
+
pySwitchbot provides helpers for SwitchBot's authorization-code flow. The
|
|
48
|
+
calling application supplies a SwitchBot-issued client ID and its registered
|
|
49
|
+
redirect URI; neither value is tied to Home Assistant or embedded in the
|
|
50
|
+
library.
|
|
51
|
+
|
|
52
|
+
SwitchBot currently treats these integrations as public clients: the token
|
|
53
|
+
request does not use a client secret, and the authorization server does not
|
|
54
|
+
support PKCE. The caller must generate an unpredictable, single-use `state`,
|
|
55
|
+
store it for the duration of the flow, and reject callbacks whose state does
|
|
56
|
+
not match. State protects the callback from request forgery but does not
|
|
57
|
+
replace PKCE.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import secrets
|
|
61
|
+
|
|
62
|
+
from switchbot import (
|
|
63
|
+
build_oauth_authorize_url,
|
|
64
|
+
exchange_oauth_code,
|
|
65
|
+
fetch_cloud_devices_by_token,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
state = secrets.token_urlsafe(32)
|
|
69
|
+
authorize_url = build_oauth_authorize_url(client_id, redirect_uri, state)
|
|
70
|
+
|
|
71
|
+
# Store state before sending the user to authorize_url. On callback:
|
|
72
|
+
if callback_state != state:
|
|
73
|
+
raise ValueError("OAuth state mismatch")
|
|
74
|
+
|
|
75
|
+
token = await exchange_oauth_code(
|
|
76
|
+
session,
|
|
77
|
+
client_id,
|
|
78
|
+
redirect_uri,
|
|
79
|
+
authorization_code,
|
|
80
|
+
)
|
|
81
|
+
devices = await fetch_cloud_devices_by_token(session, token["access_token"])
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The client ID and exact HTTPS redirect URI must be registered with SwitchBot;
|
|
85
|
+
arbitrary values and wildcard redirect URIs will not work. `exchange_oauth_code`
|
|
86
|
+
returns the provider's token mapping after validating the access token and
|
|
87
|
+
normalizing `expires_in` to an integer. The access token can then be passed to
|
|
88
|
+
`fetch_cloud_devices_by_token` or
|
|
89
|
+
`SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token`.
|
|
90
|
+
|
|
91
|
+
HTTP 401 and 403 responses from the SwitchBot account API raise
|
|
92
|
+
`SwitchbotAuthenticationError`. Other API failures raise `SwitchbotApiError`,
|
|
93
|
+
while transport and availability failures raise
|
|
94
|
+
`SwitchbotAccountConnectionError`.
|
|
95
|
+
|
|
44
96
|
## Obtaining encryption key for Switchbot Locks
|
|
45
97
|
|
|
46
98
|
Using the script `scripts/get_encryption_key.py` you can manually obtain locks encryption key.
|
|
@@ -72,11 +124,11 @@ password. The most common failures are account-side, not bugs in this library:
|
|
|
72
124
|
email/password account.
|
|
73
125
|
- The username is an email but the account is registered to a phone number
|
|
74
126
|
(or vice versa). Use the exact identifier you log in with.
|
|
75
|
-
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
127
|
+
- **`..., status code: 190`** (`SwitchbotApiError`) — authentication succeeded
|
|
128
|
+
but the key could not be read. Usually the account is not the device
|
|
129
|
+
**owner**: keys are only returned to the owning account, not to shared/family
|
|
130
|
+
members. Retrieve the key from the owner account, or transfer ownership in
|
|
131
|
+
the app.
|
|
80
132
|
|
|
81
133
|
The key only needs to be fetched once; store the `key_id` and encryption key
|
|
82
134
|
and reuse them — there is no need to call the script on every connection.
|
|
@@ -10,6 +10,57 @@ source .venv/bin/activate
|
|
|
10
10
|
pip install .
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
## OAuth account access
|
|
14
|
+
|
|
15
|
+
pySwitchbot provides helpers for SwitchBot's authorization-code flow. The
|
|
16
|
+
calling application supplies a SwitchBot-issued client ID and its registered
|
|
17
|
+
redirect URI; neither value is tied to Home Assistant or embedded in the
|
|
18
|
+
library.
|
|
19
|
+
|
|
20
|
+
SwitchBot currently treats these integrations as public clients: the token
|
|
21
|
+
request does not use a client secret, and the authorization server does not
|
|
22
|
+
support PKCE. The caller must generate an unpredictable, single-use `state`,
|
|
23
|
+
store it for the duration of the flow, and reject callbacks whose state does
|
|
24
|
+
not match. State protects the callback from request forgery but does not
|
|
25
|
+
replace PKCE.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
import secrets
|
|
29
|
+
|
|
30
|
+
from switchbot import (
|
|
31
|
+
build_oauth_authorize_url,
|
|
32
|
+
exchange_oauth_code,
|
|
33
|
+
fetch_cloud_devices_by_token,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
state = secrets.token_urlsafe(32)
|
|
37
|
+
authorize_url = build_oauth_authorize_url(client_id, redirect_uri, state)
|
|
38
|
+
|
|
39
|
+
# Store state before sending the user to authorize_url. On callback:
|
|
40
|
+
if callback_state != state:
|
|
41
|
+
raise ValueError("OAuth state mismatch")
|
|
42
|
+
|
|
43
|
+
token = await exchange_oauth_code(
|
|
44
|
+
session,
|
|
45
|
+
client_id,
|
|
46
|
+
redirect_uri,
|
|
47
|
+
authorization_code,
|
|
48
|
+
)
|
|
49
|
+
devices = await fetch_cloud_devices_by_token(session, token["access_token"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The client ID and exact HTTPS redirect URI must be registered with SwitchBot;
|
|
53
|
+
arbitrary values and wildcard redirect URIs will not work. `exchange_oauth_code`
|
|
54
|
+
returns the provider's token mapping after validating the access token and
|
|
55
|
+
normalizing `expires_in` to an integer. The access token can then be passed to
|
|
56
|
+
`fetch_cloud_devices_by_token` or
|
|
57
|
+
`SwitchbotEncryptedDevice.async_retrieve_encryption_key_by_token`.
|
|
58
|
+
|
|
59
|
+
HTTP 401 and 403 responses from the SwitchBot account API raise
|
|
60
|
+
`SwitchbotAuthenticationError`. Other API failures raise `SwitchbotApiError`,
|
|
61
|
+
while transport and availability failures raise
|
|
62
|
+
`SwitchbotAccountConnectionError`.
|
|
63
|
+
|
|
13
64
|
## Obtaining encryption key for Switchbot Locks
|
|
14
65
|
|
|
15
66
|
Using the script `scripts/get_encryption_key.py` you can manually obtain locks encryption key.
|
|
@@ -41,11 +92,11 @@ password. The most common failures are account-side, not bugs in this library:
|
|
|
41
92
|
email/password account.
|
|
42
93
|
- The username is an email but the account is registered to a phone number
|
|
43
94
|
(or vice versa). Use the exact identifier you log in with.
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
95
|
+
- **`..., status code: 190`** (`SwitchbotApiError`) — authentication succeeded
|
|
96
|
+
but the key could not be read. Usually the account is not the device
|
|
97
|
+
**owner**: keys are only returned to the owning account, not to shared/family
|
|
98
|
+
members. Retrieve the key from the owner account, or transfer ownership in
|
|
99
|
+
the app.
|
|
49
100
|
|
|
50
101
|
The key only needs to be fetched once; store the `key_id` and encryption key
|
|
51
102
|
and reuse them — there is no need to call the script on every connection.
|
|
@@ -47,6 +47,7 @@ from .devices.device import (
|
|
|
47
47
|
SwitchbotEncryptedDevice,
|
|
48
48
|
SwitchbotOperationError,
|
|
49
49
|
fetch_cloud_devices,
|
|
50
|
+
fetch_cloud_devices_by_token,
|
|
50
51
|
)
|
|
51
52
|
from .devices.evaporative_humidifier import SwitchbotEvaporativeHumidifier
|
|
52
53
|
from .devices.fan import (
|
|
@@ -79,8 +80,18 @@ from .devices.universal_remote import SwitchbotUniversalRemote
|
|
|
79
80
|
from .devices.vacuum import SwitchbotVacuum
|
|
80
81
|
from .discovery import GetSwitchbotDevices
|
|
81
82
|
from .models import SwitchBotAdvertisement
|
|
83
|
+
from .oauth import (
|
|
84
|
+
OAUTH_AUTHORIZE_URL,
|
|
85
|
+
OAUTH_SCOPE,
|
|
86
|
+
OAUTH_TOKEN_URL,
|
|
87
|
+
build_oauth_authorize_url,
|
|
88
|
+
exchange_oauth_code,
|
|
89
|
+
)
|
|
82
90
|
|
|
83
91
|
__all__ = [
|
|
92
|
+
"OAUTH_AUTHORIZE_URL",
|
|
93
|
+
"OAUTH_SCOPE",
|
|
94
|
+
"OAUTH_TOKEN_URL",
|
|
84
95
|
"AirPurifierMode",
|
|
85
96
|
"AirQualityLevel",
|
|
86
97
|
"BulbColorMode",
|
|
@@ -142,9 +153,12 @@ __all__ = [
|
|
|
142
153
|
"SwitchbotUniversalRemote",
|
|
143
154
|
"SwitchbotVacuum",
|
|
144
155
|
"VerticalOscillationAngle",
|
|
156
|
+
"build_oauth_authorize_url",
|
|
145
157
|
"close_stale_connections",
|
|
146
158
|
"close_stale_connections_by_address",
|
|
159
|
+
"exchange_oauth_code",
|
|
147
160
|
"fetch_cloud_devices",
|
|
161
|
+
"fetch_cloud_devices_by_token",
|
|
148
162
|
"get_device",
|
|
149
163
|
"parse_advertisement_data",
|
|
150
164
|
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "2.9.1"
|
|
@@ -24,7 +24,7 @@ def decode_temp_humidity(temp_data: bytes, battery: int | None) -> dict[str, Any
|
|
|
24
24
|
_temp_f = (_temp_f * 10) / 10
|
|
25
25
|
humidity = temp_data[2] & 0b01111111
|
|
26
26
|
|
|
27
|
-
if _temp_c == 0 and humidity == 0
|
|
27
|
+
if _temp_c == 0 and humidity == 0:
|
|
28
28
|
return {}
|
|
29
29
|
|
|
30
30
|
return {
|
|
@@ -39,15 +39,25 @@ from ..const import (
|
|
|
39
39
|
from ..discovery import GetSwitchbotDevices
|
|
40
40
|
from ..helpers import create_background_task
|
|
41
41
|
from ..models import SwitchBotAdvertisement
|
|
42
|
-
from ..utils import format_mac_upper
|
|
42
|
+
from ..utils import extract_request_id, format_mac_upper
|
|
43
43
|
|
|
44
44
|
_LOGGER = logging.getLogger(__name__)
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _masked_device_id(device_id: str) -> str:
|
|
48
|
+
"""Mask a device identifier while retaining a useful suffix."""
|
|
49
|
+
normalized = device_id.replace(":", "").replace("-", "").upper()
|
|
50
|
+
if not normalized:
|
|
51
|
+
return "unknown"
|
|
52
|
+
return f"****{normalized[-4:]}"
|
|
53
|
+
|
|
54
|
+
|
|
47
55
|
def _extract_region(userinfo: dict[str, Any]) -> str:
|
|
48
56
|
"""Extract region from user info, defaulting to 'us'."""
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
region = userinfo.get("botRegion")
|
|
58
|
+
if isinstance(region, str) and region:
|
|
59
|
+
return region
|
|
60
|
+
_LOGGER.warning("SwitchBot account region missing; defaulting to us")
|
|
51
61
|
return "us"
|
|
52
62
|
|
|
53
63
|
|
|
@@ -265,6 +275,10 @@ class SwitchbotBaseDevice:
|
|
|
265
275
|
return await cls.api_request(
|
|
266
276
|
session, "account", "account/api/v1/user/userinfo", {}, auth_headers
|
|
267
277
|
)
|
|
278
|
+
except SwitchbotAuthenticationError:
|
|
279
|
+
raise
|
|
280
|
+
except SwitchbotApiError:
|
|
281
|
+
raise
|
|
268
282
|
except Exception as err:
|
|
269
283
|
raise SwitchbotAccountConnectionError(
|
|
270
284
|
f"Failed to retrieve SwitchBot Account user details: {err}"
|
|
@@ -308,8 +322,45 @@ class SwitchbotBaseDevice:
|
|
|
308
322
|
except Exception as err:
|
|
309
323
|
raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
|
|
310
324
|
|
|
325
|
+
return await cls._async_get_devices(session, auth_headers)
|
|
326
|
+
|
|
327
|
+
@classmethod
|
|
328
|
+
async def get_devices_by_token(
|
|
329
|
+
cls,
|
|
330
|
+
session: aiohttp.ClientSession,
|
|
331
|
+
access_token: str,
|
|
332
|
+
) -> dict[str, SwitchbotModel]:
|
|
333
|
+
"""Get devices from SwitchBot API using an OAuth access token."""
|
|
334
|
+
started = time.monotonic()
|
|
335
|
+
_LOGGER.debug("Retrieving SwitchBot cloud devices using an OAuth token")
|
|
336
|
+
try:
|
|
337
|
+
devices = await cls._async_get_devices(
|
|
338
|
+
session, {"authorization": access_token}
|
|
339
|
+
)
|
|
340
|
+
except Exception:
|
|
341
|
+
_LOGGER.debug(
|
|
342
|
+
"SwitchBot OAuth cloud device retrieval failed; duration_ms=%s",
|
|
343
|
+
round((time.monotonic() - started) * 1000),
|
|
344
|
+
)
|
|
345
|
+
raise
|
|
346
|
+
_LOGGER.debug(
|
|
347
|
+
"SwitchBot OAuth cloud device retrieval finished; supported_devices=%s "
|
|
348
|
+
"duration_ms=%s",
|
|
349
|
+
len(devices),
|
|
350
|
+
round((time.monotonic() - started) * 1000),
|
|
351
|
+
)
|
|
352
|
+
return devices
|
|
353
|
+
|
|
354
|
+
@classmethod
|
|
355
|
+
async def _async_get_devices(
|
|
356
|
+
cls,
|
|
357
|
+
session: aiohttp.ClientSession,
|
|
358
|
+
auth_headers: dict[str, str],
|
|
359
|
+
) -> dict[str, SwitchbotModel]:
|
|
360
|
+
"""Get devices from SwitchBot API using authenticated headers."""
|
|
311
361
|
userinfo = await cls._async_get_user_info(session, auth_headers)
|
|
312
362
|
region = _extract_region(userinfo)
|
|
363
|
+
_LOGGER.debug("SwitchBot account region resolved to %s", region)
|
|
313
364
|
|
|
314
365
|
try:
|
|
315
366
|
device_info = await cls.api_request(
|
|
@@ -321,12 +372,21 @@ class SwitchbotBaseDevice:
|
|
|
321
372
|
},
|
|
322
373
|
auth_headers,
|
|
323
374
|
)
|
|
375
|
+
except SwitchbotAuthenticationError:
|
|
376
|
+
raise
|
|
377
|
+
except SwitchbotApiError:
|
|
378
|
+
raise
|
|
324
379
|
except Exception as err:
|
|
325
380
|
raise SwitchbotAccountConnectionError(
|
|
326
381
|
f"Failed to retrieve devices from SwitchBot Account: {err}"
|
|
327
382
|
) from err
|
|
328
383
|
|
|
329
|
-
items
|
|
384
|
+
items = device_info.get("Items")
|
|
385
|
+
if not isinstance(items, list) or not all(
|
|
386
|
+
isinstance(item, dict) for item in items
|
|
387
|
+
):
|
|
388
|
+
raise SwitchbotApiError("Invalid device response from SwitchBot API")
|
|
389
|
+
_LOGGER.debug("SwitchBot cloud API returned %s device records", len(items))
|
|
330
390
|
mac_to_model: dict[str, SwitchbotModel] = {}
|
|
331
391
|
|
|
332
392
|
for item in items:
|
|
@@ -360,6 +420,7 @@ class SwitchbotBaseDevice:
|
|
|
360
420
|
item,
|
|
361
421
|
)
|
|
362
422
|
|
|
423
|
+
_LOGGER.debug("Mapped %s supported SwitchBot cloud devices", len(mac_to_model))
|
|
363
424
|
return mac_to_model
|
|
364
425
|
|
|
365
426
|
@classmethod
|
|
@@ -372,18 +433,46 @@ class SwitchbotBaseDevice:
|
|
|
372
433
|
headers: dict | None = None,
|
|
373
434
|
) -> dict:
|
|
374
435
|
url = f"https://{subdomain}.{SWITCHBOT_APP_API_BASE_URL}/{path}"
|
|
436
|
+
started = time.monotonic()
|
|
437
|
+
_LOGGER.debug("Requesting SwitchBot API endpoint %s", url)
|
|
375
438
|
async with session.post(
|
|
376
439
|
url,
|
|
377
440
|
json=data,
|
|
378
441
|
headers=headers,
|
|
379
442
|
timeout=aiohttp.ClientTimeout(total=10),
|
|
380
443
|
) as result:
|
|
444
|
+
_LOGGER.debug(
|
|
445
|
+
"SwitchBot API endpoint %s returned HTTP status %s; duration_ms=%s "
|
|
446
|
+
"request_id=%s",
|
|
447
|
+
url,
|
|
448
|
+
result.status,
|
|
449
|
+
round((time.monotonic() - started) * 1000),
|
|
450
|
+
extract_request_id(result.headers) or "unavailable",
|
|
451
|
+
)
|
|
452
|
+
if result.status in (401, 403):
|
|
453
|
+
raise SwitchbotAuthenticationError(
|
|
454
|
+
"Authentication rejected by SwitchBot API"
|
|
455
|
+
)
|
|
381
456
|
if result.status > 299:
|
|
382
457
|
raise SwitchbotApiError(
|
|
383
458
|
f"Unexpected status code returned by SwitchBot API: {result.status}"
|
|
384
459
|
)
|
|
385
460
|
|
|
386
461
|
response = await result.json()
|
|
462
|
+
body = response.get("body")
|
|
463
|
+
body_fields: list[str] | str = (
|
|
464
|
+
sorted(body) if isinstance(body, dict) else type(body).__name__
|
|
465
|
+
)
|
|
466
|
+
_LOGGER.debug(
|
|
467
|
+
(
|
|
468
|
+
"SwitchBot API endpoint %s returned API status %s; "
|
|
469
|
+
"response fields=%s; body fields=%s"
|
|
470
|
+
),
|
|
471
|
+
url,
|
|
472
|
+
response.get("statusCode"),
|
|
473
|
+
sorted(response),
|
|
474
|
+
body_fields,
|
|
475
|
+
)
|
|
387
476
|
if response["statusCode"] != 100:
|
|
388
477
|
raise SwitchbotApiError(
|
|
389
478
|
f"{response['message']}, status code: {response['statusCode']}"
|
|
@@ -1052,17 +1141,69 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1052
1141
|
password: str,
|
|
1053
1142
|
) -> dict:
|
|
1054
1143
|
"""Retrieve lock key from internal SwitchBot API."""
|
|
1055
|
-
device_mac = device_mac.replace(":", "").replace("-", "").upper()
|
|
1056
|
-
|
|
1057
1144
|
try:
|
|
1058
1145
|
auth_result = await cls._get_auth_result(session, username, password)
|
|
1059
1146
|
auth_headers = {"authorization": auth_result["access_token"]}
|
|
1060
1147
|
except Exception as err:
|
|
1061
1148
|
raise SwitchbotAuthenticationError(f"Authentication failed: {err}") from err
|
|
1062
1149
|
|
|
1150
|
+
return await cls._async_retrieve_encryption_key(
|
|
1151
|
+
session, device_mac, auth_headers
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
@classmethod
|
|
1155
|
+
async def async_retrieve_encryption_key_by_token(
|
|
1156
|
+
cls,
|
|
1157
|
+
session: aiohttp.ClientSession,
|
|
1158
|
+
device_mac: str,
|
|
1159
|
+
access_token: str,
|
|
1160
|
+
) -> dict:
|
|
1161
|
+
"""Retrieve an encryption key using an OAuth access token."""
|
|
1162
|
+
started = time.monotonic()
|
|
1163
|
+
masked_device = _masked_device_id(device_mac)
|
|
1164
|
+
_LOGGER.debug(
|
|
1165
|
+
"Retrieving a SwitchBot encryption key using an OAuth token; device=%s",
|
|
1166
|
+
masked_device,
|
|
1167
|
+
)
|
|
1168
|
+
try:
|
|
1169
|
+
key_details = await cls._async_retrieve_encryption_key(
|
|
1170
|
+
session, device_mac, {"authorization": access_token}
|
|
1171
|
+
)
|
|
1172
|
+
except Exception:
|
|
1173
|
+
_LOGGER.debug(
|
|
1174
|
+
"SwitchBot OAuth encryption key retrieval failed; device=%s "
|
|
1175
|
+
"duration_ms=%s",
|
|
1176
|
+
masked_device,
|
|
1177
|
+
round((time.monotonic() - started) * 1000),
|
|
1178
|
+
)
|
|
1179
|
+
raise
|
|
1180
|
+
_LOGGER.debug(
|
|
1181
|
+
"SwitchBot OAuth encryption key retrieval finished; device=%s "
|
|
1182
|
+
"duration_ms=%s",
|
|
1183
|
+
masked_device,
|
|
1184
|
+
round((time.monotonic() - started) * 1000),
|
|
1185
|
+
)
|
|
1186
|
+
return key_details
|
|
1187
|
+
|
|
1188
|
+
@classmethod
|
|
1189
|
+
async def _async_retrieve_encryption_key(
|
|
1190
|
+
cls,
|
|
1191
|
+
session: aiohttp.ClientSession,
|
|
1192
|
+
device_mac: str,
|
|
1193
|
+
auth_headers: dict[str, str],
|
|
1194
|
+
) -> dict:
|
|
1195
|
+
"""Retrieve an encryption key using authenticated headers."""
|
|
1196
|
+
device_mac = device_mac.replace(":", "").replace("-", "").upper()
|
|
1197
|
+
|
|
1063
1198
|
userinfo = await cls._async_get_user_info(session, auth_headers)
|
|
1064
1199
|
region = _extract_region(userinfo)
|
|
1200
|
+
masked_device = _masked_device_id(device_mac)
|
|
1065
1201
|
|
|
1202
|
+
_LOGGER.debug(
|
|
1203
|
+
"SwitchBot encryption key account region resolved; region=%s device=%s",
|
|
1204
|
+
region,
|
|
1205
|
+
masked_device,
|
|
1206
|
+
)
|
|
1066
1207
|
try:
|
|
1067
1208
|
device_info = await cls.api_request(
|
|
1068
1209
|
session,
|
|
@@ -1074,16 +1215,33 @@ class SwitchbotEncryptedDevice(SwitchbotDevice):
|
|
|
1074
1215
|
},
|
|
1075
1216
|
auth_headers,
|
|
1076
1217
|
)
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
}
|
|
1218
|
+
except SwitchbotAuthenticationError:
|
|
1219
|
+
raise
|
|
1220
|
+
except SwitchbotApiError:
|
|
1221
|
+
raise
|
|
1082
1222
|
except Exception as err:
|
|
1083
1223
|
raise SwitchbotAccountConnectionError(
|
|
1084
1224
|
f"Failed to retrieve encryption key from SwitchBot Account: {err}"
|
|
1085
1225
|
) from err
|
|
1086
1226
|
|
|
1227
|
+
communication_key = device_info.get("communicationKey")
|
|
1228
|
+
if not isinstance(communication_key, dict):
|
|
1229
|
+
raise SwitchbotApiError(
|
|
1230
|
+
"Invalid encryption key response from SwitchBot API"
|
|
1231
|
+
)
|
|
1232
|
+
key_id = communication_key.get("keyId")
|
|
1233
|
+
encryption_key = communication_key.get("key")
|
|
1234
|
+
if not isinstance(key_id, str) or not isinstance(encryption_key, str):
|
|
1235
|
+
raise SwitchbotApiError(
|
|
1236
|
+
"Invalid encryption key response from SwitchBot API"
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
_LOGGER.debug(
|
|
1240
|
+
"SwitchBot encryption key retrieved successfully; device=%s",
|
|
1241
|
+
masked_device,
|
|
1242
|
+
)
|
|
1243
|
+
return {"key_id": key_id, "encryption_key": encryption_key}
|
|
1244
|
+
|
|
1087
1245
|
@classmethod
|
|
1088
1246
|
async def verify_encryption_key(
|
|
1089
1247
|
cls,
|
|
@@ -1336,3 +1494,11 @@ async def fetch_cloud_devices(
|
|
|
1336
1494
|
"""Fetch devices from SwitchBot API and return MAC to model mapping."""
|
|
1337
1495
|
# Get devices from the API (which also populates the cache)
|
|
1338
1496
|
return await SwitchbotBaseDevice.get_devices(session, username, password)
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
async def fetch_cloud_devices_by_token(
|
|
1500
|
+
session: aiohttp.ClientSession,
|
|
1501
|
+
access_token: str,
|
|
1502
|
+
) -> dict[str, SwitchbotModel]:
|
|
1503
|
+
"""Fetch devices from SwitchBot API using an OAuth access token."""
|
|
1504
|
+
return await SwitchbotBaseDevice.get_devices_by_token(session, access_token)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""OAuth helpers for the SwitchBot account API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from time import monotonic
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlencode, urlsplit
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
|
|
13
|
+
from .const import (
|
|
14
|
+
SwitchbotAccountConnectionError,
|
|
15
|
+
SwitchbotApiError,
|
|
16
|
+
SwitchbotAuthenticationError,
|
|
17
|
+
)
|
|
18
|
+
from .utils import extract_request_id
|
|
19
|
+
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
OAUTH_AUTHORIZE_URL = "https://sp.oauth.switchbot.net"
|
|
23
|
+
OAUTH_TOKEN_URL = "https://account.api.switchbot.net/merchant/v1/oauth/token"
|
|
24
|
+
OAUTH_SCOPE = "api_login"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _oauth_error_field(
|
|
28
|
+
error_data: Any, field: str, authorization_code: str
|
|
29
|
+
) -> str | None:
|
|
30
|
+
"""Return a bounded OAuth error field with the authorization code redacted."""
|
|
31
|
+
if not isinstance(error_data, Mapping):
|
|
32
|
+
return None
|
|
33
|
+
value = error_data.get(field)
|
|
34
|
+
if not isinstance(value, str):
|
|
35
|
+
return None
|
|
36
|
+
value = " ".join(value.split())
|
|
37
|
+
if authorization_code:
|
|
38
|
+
value = value.replace(authorization_code, "<redacted>")
|
|
39
|
+
return value[:256] or None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _raise_for_oauth_error(status: int, error: str | None, error_suffix: str) -> None:
|
|
43
|
+
"""Raise the appropriate exception for an OAuth error response."""
|
|
44
|
+
if status in (401, 403) or (
|
|
45
|
+
status == 400 and error in {"invalid_client", "invalid_grant"}
|
|
46
|
+
):
|
|
47
|
+
raise SwitchbotAuthenticationError(
|
|
48
|
+
f"SwitchBot OAuth token request rejected ({status}){error_suffix}"
|
|
49
|
+
)
|
|
50
|
+
if 400 <= status < 500 and status != 429:
|
|
51
|
+
raise SwitchbotApiError(
|
|
52
|
+
f"SwitchBot OAuth token request failed ({status}){error_suffix}"
|
|
53
|
+
)
|
|
54
|
+
if status == 429 or status >= 500:
|
|
55
|
+
raise SwitchbotAccountConnectionError(
|
|
56
|
+
f"SwitchBot OAuth token service unavailable ({status}){error_suffix}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_oauth_authorize_url(
|
|
61
|
+
client_id: str,
|
|
62
|
+
redirect_uri: str,
|
|
63
|
+
state: str,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Build a SwitchBot OAuth authorization URL."""
|
|
66
|
+
_LOGGER.debug(
|
|
67
|
+
"Building SwitchBot OAuth authorization request; authorize_host=%s "
|
|
68
|
+
"redirect_host=%s",
|
|
69
|
+
urlsplit(OAUTH_AUTHORIZE_URL).hostname,
|
|
70
|
+
urlsplit(redirect_uri).hostname,
|
|
71
|
+
)
|
|
72
|
+
query = urlencode(
|
|
73
|
+
{
|
|
74
|
+
"client_id": client_id,
|
|
75
|
+
"redirect_uri": redirect_uri,
|
|
76
|
+
"response_type": "code",
|
|
77
|
+
"scope": OAUTH_SCOPE,
|
|
78
|
+
"state": state,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
return f"{OAUTH_AUTHORIZE_URL}?{query}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def exchange_oauth_code(
|
|
85
|
+
session: aiohttp.ClientSession,
|
|
86
|
+
client_id: str,
|
|
87
|
+
redirect_uri: str,
|
|
88
|
+
code: str,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
"""Exchange an OAuth authorization code for a SwitchBot access token."""
|
|
91
|
+
started = monotonic()
|
|
92
|
+
_LOGGER.debug(
|
|
93
|
+
"Exchanging SwitchBot OAuth authorization code; token_host=%s",
|
|
94
|
+
urlsplit(OAUTH_TOKEN_URL).hostname,
|
|
95
|
+
)
|
|
96
|
+
error: str | None = None
|
|
97
|
+
error_description: str | None = None
|
|
98
|
+
token_data: Any = None
|
|
99
|
+
try:
|
|
100
|
+
async with session.post(
|
|
101
|
+
OAUTH_TOKEN_URL,
|
|
102
|
+
data={
|
|
103
|
+
"code": code,
|
|
104
|
+
"client_id": client_id,
|
|
105
|
+
"grant_type": "authorization_code",
|
|
106
|
+
"redirect_uri": redirect_uri,
|
|
107
|
+
},
|
|
108
|
+
timeout=aiohttp.ClientTimeout(total=10),
|
|
109
|
+
) as response:
|
|
110
|
+
status = response.status
|
|
111
|
+
_LOGGER.debug(
|
|
112
|
+
"SwitchBot OAuth token endpoint returned HTTP status %s; "
|
|
113
|
+
"duration_ms=%s request_id=%s",
|
|
114
|
+
status,
|
|
115
|
+
round((monotonic() - started) * 1000),
|
|
116
|
+
extract_request_id(response.headers) or "unavailable",
|
|
117
|
+
)
|
|
118
|
+
if status >= 400:
|
|
119
|
+
try:
|
|
120
|
+
error_data = await response.json()
|
|
121
|
+
except (aiohttp.ClientError, ValueError, TypeError) as err:
|
|
122
|
+
_LOGGER.debug(
|
|
123
|
+
"SwitchBot OAuth token error response could not be parsed; "
|
|
124
|
+
"error_type=%s",
|
|
125
|
+
type(err).__name__,
|
|
126
|
+
)
|
|
127
|
+
error_data = None
|
|
128
|
+
error = _oauth_error_field(error_data, "error", code)
|
|
129
|
+
error_description = _oauth_error_field(
|
|
130
|
+
error_data, "error_description", code
|
|
131
|
+
)
|
|
132
|
+
_LOGGER.debug(
|
|
133
|
+
"SwitchBot OAuth token endpoint returned an error response; "
|
|
134
|
+
"error=%s error_description=%s",
|
|
135
|
+
error or "unavailable",
|
|
136
|
+
error_description or "unavailable",
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
try:
|
|
140
|
+
token_data = await response.json()
|
|
141
|
+
except (aiohttp.ClientError, ValueError, TypeError) as err:
|
|
142
|
+
raise SwitchbotApiError(
|
|
143
|
+
"Invalid response from SwitchBot OAuth token API"
|
|
144
|
+
) from err
|
|
145
|
+
except (aiohttp.ClientError, TimeoutError) as err:
|
|
146
|
+
raise SwitchbotAccountConnectionError(
|
|
147
|
+
f"Failed to connect to SwitchBot OAuth token API: {err}"
|
|
148
|
+
) from err
|
|
149
|
+
|
|
150
|
+
error_detail = ": ".join(
|
|
151
|
+
value for value in (error, error_description) if value is not None
|
|
152
|
+
)
|
|
153
|
+
error_suffix = f": {error_detail}" if error_detail else ""
|
|
154
|
+
_raise_for_oauth_error(status, error, error_suffix)
|
|
155
|
+
if not isinstance(token_data, dict):
|
|
156
|
+
raise SwitchbotApiError("Invalid response from SwitchBot OAuth token API")
|
|
157
|
+
|
|
158
|
+
token: dict[str, Any] = token_data.copy()
|
|
159
|
+
_LOGGER.debug("SwitchBot OAuth token response fields: %s", sorted(token))
|
|
160
|
+
access_token = token.get("access_token")
|
|
161
|
+
expires_in = token.get("expires_in")
|
|
162
|
+
if (
|
|
163
|
+
not isinstance(access_token, str)
|
|
164
|
+
or not access_token
|
|
165
|
+
or isinstance(expires_in, bool)
|
|
166
|
+
or not isinstance(expires_in, int | str)
|
|
167
|
+
):
|
|
168
|
+
raise SwitchbotApiError("Invalid token data from SwitchBot OAuth token API")
|
|
169
|
+
try:
|
|
170
|
+
normalized_expires_in = int(expires_in)
|
|
171
|
+
except ValueError as err:
|
|
172
|
+
raise SwitchbotApiError(
|
|
173
|
+
"Invalid token data from SwitchBot OAuth token API"
|
|
174
|
+
) from err
|
|
175
|
+
token["expires_in"] = normalized_expires_in
|
|
176
|
+
_LOGGER.debug(
|
|
177
|
+
"SwitchBot OAuth token response validated; expires_in=%s "
|
|
178
|
+
"refresh_token_present=%s refresh_expires_in_present=%s",
|
|
179
|
+
normalized_expires_in,
|
|
180
|
+
bool(token.get("refresh_token")),
|
|
181
|
+
"refresh_expires_in" in token,
|
|
182
|
+
)
|
|
183
|
+
return token
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
"""Utility functions for switchbot."""
|
|
2
2
|
|
|
3
|
+
from collections.abc import Mapping
|
|
3
4
|
from functools import lru_cache
|
|
4
5
|
|
|
6
|
+
_REQUEST_ID_HEADERS = ("x-request-id", "x-amzn-requestid", "cf-ray")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def extract_request_id(headers: Mapping[str, str]) -> str | None:
|
|
10
|
+
"""Extract a provider request identifier for log correlation."""
|
|
11
|
+
normalized_headers = {name.lower(): value for name, value in headers.items()}
|
|
12
|
+
return next(
|
|
13
|
+
(
|
|
14
|
+
value
|
|
15
|
+
for name in _REQUEST_ID_HEADERS
|
|
16
|
+
if (value := normalized_headers.get(name))
|
|
17
|
+
),
|
|
18
|
+
None,
|
|
19
|
+
)
|
|
20
|
+
|
|
5
21
|
|
|
6
22
|
@lru_cache(maxsize=512)
|
|
7
23
|
def format_mac_upper(mac: str) -> str:
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "2.8.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|