Skip to content

Commit ffd3a8a

Browse files
authored
Merge branch 'main' into multi_device
2 parents 5f8374b + a8b32f1 commit ffd3a8a

File tree

19 files changed

+589
-331
lines changed

19 files changed

+589
-331
lines changed

.github/labeler.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
dependencies:
2+
- changed-files:
3+
- any-glob-to-any-file:
4+
- '.pre-commit-config.yaml'

.github/workflows/docker.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
uses: docker/[email protected]
4848
-
4949
name: Build and Push
50-
uses: docker/build-push-action@v6.15.0
50+
uses: docker/build-push-action@v6.16.0
5151
with:
5252
context: .
5353
tags: |

.github/workflows/labeler.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: Add labels
2+
3+
on:
4+
- pull_request_target
5+
6+
jobs:
7+
labeler:
8+
permissions:
9+
contents: read
10+
pull-requests: write
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/[email protected]

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ jobs:
106106
echo "CIBW_BUILD=${{ matrix.pyver }}*" >> $GITHUB_ENV
107107
fi
108108
- name: Build wheels
109-
uses: pypa/[email protected].2
109+
uses: pypa/[email protected].3
110110
env:
111111
CIBW_SKIP: cp36-* cp37-* cp38-* pp* ${{ matrix.musl == 'musllinux' && '*manylinux*' || '*musllinux*' }}
112112
CIBW_BEFORE_ALL_LINUX: apt-get install -y gcc || yum install -y gcc || apk add gcc

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ repos:
1313
- id: pyupgrade
1414
args: [--py39-plus]
1515
- repo: https://github.com/astral-sh/ruff-pre-commit
16-
rev: v0.11.6
16+
rev: v0.11.8
1717
hooks:
1818
- id: ruff
1919
args: [--fix]

aioesphomeapi/_frame_helper/noise_encryption.pxd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ cdef class EncryptCipher:
77
cdef object _nonce
88
cdef object _encrypt
99

10-
cdef bytes encrypt(self, object frame)
10+
cpdef bytes encrypt(self, object frame)
1111

1212
cdef class DecryptCipher:
1313

aioesphomeapi/api.proto

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ service APIConnection {
3131
option (needs_authentication) = false;
3232
}
3333
rpc execute_service (ExecuteServiceRequest) returns (void) {}
34+
rpc noise_encryption_set_key (NoiseEncryptionSetKeyRequest) returns (NoiseEncryptionSetKeyResponse) {}
3435

3536
rpc cover_command (CoverCommandRequest) returns (void) {}
3637
rpc fan_command (FanCommandRequest) returns (void) {}
@@ -59,6 +60,7 @@ service APIConnection {
5960
rpc bluetooth_gatt_write_descriptor(BluetoothGATTWriteDescriptorRequest) returns (void) {}
6061
rpc bluetooth_gatt_notify(BluetoothGATTNotifyRequest) returns (void) {}
6162
rpc unsubscribe_bluetooth_le_advertisements(UnsubscribeBluetoothLEAdvertisementsRequest) returns (void) {}
63+
rpc bluetooth_scanner_set_mode(BluetoothScannerSetModeRequest) returns (void) {}
6264

6365
rpc subscribe_voice_assistant(SubscribeVoiceAssistantRequest) returns (void) {}
6466

@@ -234,7 +236,10 @@ message DeviceInfoResponse {
234236
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
235237
string bluetooth_mac_address = 18;
236238

237-
repeated SubDeviceInfo sub_devices = 19;
239+
// Supports receiving and saving api encryption key
240+
bool api_encryption_supported = 19;
241+
242+
repeated SubDeviceInfo sub_devices = 20;
238243
}
239244

240245
message ListEntitiesRequest {
@@ -653,6 +658,23 @@ message SubscribeLogsResponse {
653658
bool send_failed = 4;
654659
}
655660

661+
// ==================== NOISE ENCRYPTION ====================
662+
message NoiseEncryptionSetKeyRequest {
663+
option (id) = 124;
664+
option (source) = SOURCE_CLIENT;
665+
option (ifdef) = "USE_API_NOISE";
666+
667+
bytes key = 1;
668+
}
669+
670+
message NoiseEncryptionSetKeyResponse {
671+
option (id) = 125;
672+
option (source) = SOURCE_SERVER;
673+
option (ifdef) = "USE_API_NOISE";
674+
675+
bool success = 1;
676+
}
677+
656678
// ==================== HOMEASSISTANT.SERVICE ====================
657679
message SubscribeHomeassistantServicesRequest {
658680
option (id) = 34;
@@ -1496,6 +1518,37 @@ message BluetoothDeviceClearCacheResponse {
14961518
int32 error = 3;
14971519
}
14981520

1521+
enum BluetoothScannerState {
1522+
BLUETOOTH_SCANNER_STATE_IDLE = 0;
1523+
BLUETOOTH_SCANNER_STATE_STARTING = 1;
1524+
BLUETOOTH_SCANNER_STATE_RUNNING = 2;
1525+
BLUETOOTH_SCANNER_STATE_FAILED = 3;
1526+
BLUETOOTH_SCANNER_STATE_STOPPING = 4;
1527+
BLUETOOTH_SCANNER_STATE_STOPPED = 5;
1528+
}
1529+
1530+
enum BluetoothScannerMode {
1531+
BLUETOOTH_SCANNER_MODE_PASSIVE = 0;
1532+
BLUETOOTH_SCANNER_MODE_ACTIVE = 1;
1533+
}
1534+
1535+
message BluetoothScannerStateResponse {
1536+
option(id) = 126;
1537+
option(source) = SOURCE_SERVER;
1538+
option(ifdef) = "USE_BLUETOOTH_PROXY";
1539+
1540+
BluetoothScannerState state = 1;
1541+
BluetoothScannerMode mode = 2;
1542+
}
1543+
1544+
message BluetoothScannerSetModeRequest {
1545+
option(id) = 127;
1546+
option(source) = SOURCE_CLIENT;
1547+
option(ifdef) = "USE_BLUETOOTH_PROXY";
1548+
1549+
BluetoothScannerMode mode = 1;
1550+
}
1551+
14991552
// ==================== VOICE ASSISTANT ====================
15001553
enum VoiceAssistantSubscribeFlag {
15011554
VOICE_ASSISTANT_SUBSCRIBE_NONE = 0;

aioesphomeapi/api_pb2.py

Lines changed: 335 additions & 317 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

aioesphomeapi/client.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
BluetoothGATTWriteResponse,
3333
BluetoothLEAdvertisementResponse,
3434
BluetoothLERawAdvertisementsResponse,
35+
BluetoothScannerSetModeRequest,
36+
BluetoothScannerStateResponse,
3537
ButtonCommandRequest,
3638
CameraImageRequest,
3739
CameraImageResponse,
@@ -52,6 +54,8 @@
5254
ListEntitiesServicesResponse,
5355
LockCommandRequest,
5456
MediaPlayerCommandRequest,
57+
NoiseEncryptionSetKeyRequest,
58+
NoiseEncryptionSetKeyResponse,
5559
NumberCommandRequest,
5660
SelectCommandRequest,
5761
SirenCommandRequest,
@@ -90,6 +94,7 @@
9094
on_bluetooth_handle_message,
9195
on_bluetooth_le_advertising_response,
9296
on_bluetooth_message_types,
97+
on_bluetooth_scanner_state_response,
9398
on_home_assistant_service_response,
9499
on_state_msg,
95100
on_subscribe_home_assistant_state_response,
@@ -115,6 +120,8 @@
115120
BluetoothLEAdvertisement,
116121
BluetoothProxyFeature,
117122
BluetoothProxySubscriptionFlag,
123+
BluetoothScannerMode,
124+
BluetoothScannerStateResponse as BluetoothScannerStateResponseModel,
118125
ClimateFanMode,
119126
ClimateMode,
120127
ClimatePreset,
@@ -130,6 +137,7 @@
130137
LockCommand,
131138
LogLevel,
132139
MediaPlayerCommand,
140+
NoiseEncryptionSetKeyResponse as NoiseEncryptionSetKeyResponseModel,
133141
UpdateCommand,
134142
UserService,
135143
UserServiceArgType,
@@ -407,6 +415,25 @@ def subscribe_bluetooth_connections_free(
407415
(BluetoothConnectionsFreeResponse,),
408416
)
409417

418+
def subscribe_bluetooth_scanner_state(
419+
self,
420+
on_bluetooth_scanner_state: Callable[
421+
[BluetoothScannerStateResponseModel], None
422+
],
423+
) -> Callable[[], None]:
424+
"""Subscribe to Bluetooth scanner state updates."""
425+
return self._get_connection().add_message_callback(
426+
partial(
427+
on_bluetooth_scanner_state_response,
428+
on_bluetooth_scanner_state,
429+
),
430+
(BluetoothScannerStateResponse,),
431+
)
432+
433+
def bluetooth_scanner_set_mode(self, mode: BluetoothScannerMode) -> None:
434+
"""Set the Bluetooth scanner mode."""
435+
self._get_connection().send_message(BluetoothScannerSetModeRequest(mode=mode))
436+
410437
async def bluetooth_device_connect( # pylint: disable=too-many-locals, too-many-branches
411438
self,
412439
address: int,
@@ -1373,3 +1400,14 @@ def alarm_control_panel_command(
13731400
if code is not None:
13741401
req.code = code
13751402
self._get_connection().send_message(req)
1403+
1404+
async def noise_encryption_set_key(
1405+
self,
1406+
key: bytes,
1407+
) -> bool:
1408+
"""Set the noise encryption key."""
1409+
req = NoiseEncryptionSetKeyRequest(key=key)
1410+
resp = await self._get_connection().send_message_await_response(
1411+
req, NoiseEncryptionSetKeyResponse
1412+
)
1413+
return NoiseEncryptionSetKeyResponseModel.from_pb(resp).success

aioesphomeapi/client_base.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
BluetoothGATTReadResponse,
2323
BluetoothGATTWriteResponse,
2424
BluetoothLEAdvertisementResponse,
25+
BluetoothScannerStateResponse,
2526
CameraImageResponse,
2627
HomeassistantServiceResponse,
2728
SubscribeHomeAssistantStateResponse,
@@ -31,6 +32,7 @@
3132
from .model import (
3233
APIVersion,
3334
BluetoothLEAdvertisement,
35+
BluetoothScannerStateResponse as BluetoothScannerStateResponseModel,
3436
CameraState,
3537
EntityState,
3638
HomeassistantServiceCall,
@@ -113,6 +115,13 @@ def on_bluetooth_gatt_notify_data_response(
113115
on_bluetooth_gatt_notify(handle, bytearray(msg.data))
114116

115117

118+
def on_bluetooth_scanner_state_response(
119+
on_bluetooth_scanner_state: Callable[[BluetoothScannerStateResponseModel], None],
120+
msg: BluetoothScannerStateResponse,
121+
) -> None:
122+
on_bluetooth_scanner_state(BluetoothScannerStateResponseModel.from_pb(msg))
123+
124+
116125
def on_subscribe_home_assistant_state_response(
117126
on_state_sub: Callable[[str, str | None], None],
118127
on_state_request: Callable[[str, str | None], None] | None,

aioesphomeapi/core.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
BluetoothGATTWriteResponse,
3131
BluetoothLEAdvertisementResponse,
3232
BluetoothLERawAdvertisementsResponse,
33+
BluetoothScannerSetModeRequest,
34+
BluetoothScannerStateResponse,
3335
ButtonCommandRequest,
3436
CameraImageRequest,
3537
CameraImageResponse,
@@ -89,6 +91,8 @@
8991
LockStateResponse,
9092
MediaPlayerCommandRequest,
9193
MediaPlayerStateResponse,
94+
NoiseEncryptionSetKeyRequest,
95+
NoiseEncryptionSetKeyResponse,
9296
NumberCommandRequest,
9397
NumberStateResponse,
9498
PingRequest,
@@ -438,6 +442,10 @@ def __init__(self, error: BluetoothGATTError) -> None:
438442
121: VoiceAssistantConfigurationRequest,
439443
122: VoiceAssistantConfigurationResponse,
440444
123: VoiceAssistantSetConfiguration,
445+
124: NoiseEncryptionSetKeyRequest,
446+
125: NoiseEncryptionSetKeyResponse,
447+
126: BluetoothScannerStateResponse,
448+
127: BluetoothScannerSetModeRequest,
441449
}
442450

443451
MESSAGE_NUMBER_TO_PROTO = tuple(MESSAGE_TYPE_TO_PROTO.values())

aioesphomeapi/model.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ class BluetoothProxyFeature(enum.IntFlag):
114114
PAIRING = 1 << 3
115115
CACHE_CLEARING = 1 << 4
116116
RAW_ADVERTISEMENTS = 1 << 5
117+
FEATURE_STATE_AND_MODE = 1 << 6
117118

118119

119120
class BluetoothProxySubscriptionFlag(enum.IntFlag):
@@ -1288,6 +1289,30 @@ class BluetoothDeviceRequestType(APIIntEnum):
12881289
CLEAR_CACHE = 6
12891290

12901291

1292+
class BluetoothScannerState(APIIntEnum):
1293+
IDLE = 0
1294+
STARTING = 1
1295+
RUNNING = 2
1296+
FAILED = 3
1297+
STOPPING = 4
1298+
STOPPED = 5
1299+
1300+
1301+
class BluetoothScannerMode(APIIntEnum):
1302+
PASSIVE = 0
1303+
ACTIVE = 1
1304+
1305+
1306+
@_frozen_dataclass_decorator
1307+
class BluetoothScannerStateResponse(APIModelBase):
1308+
state: BluetoothScannerState | None = converter_field(
1309+
default=BluetoothScannerState.IDLE, converter=BluetoothScannerState.convert
1310+
)
1311+
mode: BluetoothScannerMode | None = converter_field(
1312+
default=BluetoothScannerMode.PASSIVE, converter=BluetoothScannerMode.convert
1313+
)
1314+
1315+
12911316
class VoiceAssistantCommandFlag(enum.IntFlag):
12921317
USE_VAD = 1 << 0
12931318
USE_WAKE_WORD = 1 << 1
@@ -1359,6 +1384,16 @@ class VoiceAssistantSetConfiguration(APIModelBase):
13591384
active_wake_words: list[int] = converter_field(default_factory=list, converter=list)
13601385

13611386

1387+
@_frozen_dataclass_decorator
1388+
class NoiseEncryptionSetKeyRequest(APIModelBase):
1389+
key: bytes = field(default_factory=bytes) # pylint: disable=invalid-field-call
1390+
1391+
1392+
@_frozen_dataclass_decorator
1393+
class NoiseEncryptionSetKeyResponse(APIModelBase):
1394+
success: bool = False
1395+
1396+
13621397
class LogLevel(APIIntEnum):
13631398
LOG_LEVEL_NONE = 0
13641399
LOG_LEVEL_ERROR = 1

requirements/test.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
pylint==3.3.6
2-
ruff==0.11.6
1+
pylint==3.3.7
2+
ruff==0.11.8
33
flake8==7.2.0
44
isort==6.0.1
55
mypy==1.15.0
6-
types-protobuf==5.29.1.20250403
6+
types-protobuf==6.30.2.20250503
77
pytest>=6.2.4,<9
88
pytest-asyncio==0.26.0
99
pytest-codspeed==3.2.0

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
long_description = readme_file.read()
4141

4242

43-
VERSION = "30.0.2"
43+
VERSION = "31.0.0"
4444
PROJECT_NAME = "aioesphomeapi"
4545
PROJECT_PACKAGE_NAME = "aioesphomeapi"
4646
PROJECT_LICENSE = "MIT"

tests/benchmarks/test_noise.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import pytest
88
from pytest_codspeed import BenchmarkFixture # type: ignore[import-untyped]
99

10+
from aioesphomeapi._frame_helper.noise_encryption import EncryptCipher
11+
1012
from ..common import (
1113
MockAPINoiseFrameHelper,
1214
_extract_encrypted_payload_from_handshake,
@@ -78,10 +80,11 @@ def _empty_writelines(data: Iterable[bytes]):
7880
helper._writelines = _empty_writelines
7981

8082
payload = b"x" * payload_size
83+
encrypt_cipher = EncryptCipher(proto.noise_protocol.cipher_state_encrypt)
8184

8285
@benchmark
8386
def process_encrypted_packets():
8487
for _ in range(100):
85-
helper.data_received(_make_encrypted_packet(proto, 42, payload))
88+
helper.data_received(_make_encrypted_packet(encrypt_cipher, 42, payload))
8689

8790
helper.close()

0 commit comments

Comments
 (0)