Skip to content

Commit e572f8d

Browse files
authored
Fix Shelly Bluetooth discovery for Gen3/Gen4 devices without advertised names (home-assistant#156883)
1 parent 482b5d4 commit e572f8d

File tree

4 files changed

+139
-5
lines changed

4 files changed

+139
-5
lines changed

homeassistant/components/shelly/config_flow.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from contextlib import asynccontextmanager
88
from typing import TYPE_CHECKING, Any, Final
99

10-
from aioshelly.ble.manufacturer_data import has_rpc_over_ble
10+
from aioshelly.ble import get_name_from_model_id
11+
from aioshelly.ble.manufacturer_data import (
12+
has_rpc_over_ble,
13+
parse_shelly_manufacturer_data,
14+
)
1115
from aioshelly.ble.provisioning import async_provision_wifi, async_scan_wifi_networks
1216
from aioshelly.block_device import BlockDevice
1317
from aioshelly.common import ConnectionOptions, get_info
@@ -358,8 +362,35 @@ async def async_step_bluetooth(
358362
self, discovery_info: BluetoothServiceInfoBleak
359363
) -> ConfigFlowResult:
360364
"""Handle bluetooth discovery."""
361-
# Parse MAC address from the Bluetooth device name
362-
if not (mac := mac_address_from_name(discovery_info.name)):
365+
# Try to parse MAC address from the Bluetooth device name
366+
# If not found, try to get it from manufacturer data
367+
device_name = discovery_info.name
368+
if (
369+
not (mac := mac_address_from_name(device_name))
370+
and (
371+
parsed := parse_shelly_manufacturer_data(
372+
discovery_info.manufacturer_data
373+
)
374+
)
375+
and (mac_with_colons := parsed.get("mac"))
376+
and isinstance(mac_with_colons, str)
377+
):
378+
# parse_shelly_manufacturer_data returns MAC with colons (e.g., "CC:BA:97:C2:D6:72")
379+
# Convert to format without colons to match mac_address_from_name output
380+
mac = mac_with_colons.replace(":", "")
381+
# For devices without a Shelly name, use model name from model ID if available
382+
# Gen3/4 devices advertise MAC address as name instead of "ShellyXXX-MACADDR"
383+
if (
384+
(model_id := parsed.get("model_id"))
385+
and isinstance(model_id, int)
386+
and (model_name := get_name_from_model_id(model_id))
387+
):
388+
# Remove spaces from model name (e.g., "Shelly 1 Mini Gen4" -> "Shelly1MiniGen4")
389+
device_name = f"{model_name.replace(' ', '')}-{mac}"
390+
else:
391+
device_name = f"Shelly-{mac}"
392+
393+
if not mac:
363394
return self.async_abort(reason="invalid_discovery_info")
364395

365396
# Check if RPC-over-BLE is enabled - required for WiFi provisioning
@@ -381,10 +412,10 @@ async def async_step_bluetooth(
381412
if not self.ble_device:
382413
return self.async_abort(reason="cannot_connect")
383414

384-
self.device_name = discovery_info.name
415+
self.device_name = device_name
385416
self.context.update(
386417
{
387-
"title_placeholders": {"name": discovery_info.name},
418+
"title_placeholders": {"name": device_name},
388419
}
389420
)
390421

homeassistant/components/shelly/manifest.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
"bluetooth": [
55
{
66
"local_name": "Shelly*"
7+
},
8+
{
9+
"manufacturer_id": 2985
710
}
811
],
912
"codeowners": ["@bieniu", "@thecode", "@chemelli74", "@bdraco"],

homeassistant/generated/bluetooth.py

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

tests/components/shelly/test_config_flow.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@
9090
BLE_MANUFACTURER_DATA_NO_RPC = {
9191
0x0BA9: bytes([0x01, 0x02, 0x00])
9292
} # Flags without RPC bit
93+
BLE_MANUFACTURER_DATA_WITH_MAC = {
94+
0x0BA9: bytes.fromhex("0105000b30100a70d6c297bacc")
95+
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x30, 0x10), MAC (0x0a, 0x70, 0xd6, 0xc2, 0x97, 0xba, 0xcc)
96+
# Device WiFi MAC: 70d6c297bacc (little-endian) -> CCBA97C2D670 (reversed to big-endian)
97+
# BLE MAC is typically device MAC + 2: CCBA97C2D670 + 2 = CC:BA:97:C2:D6:72
98+
99+
BLE_MANUFACTURER_DATA_WITH_MAC_UNKNOWN_MODEL = {
100+
0x0BA9: bytes.fromhex("0105000b99990a70d6c297bacc")
101+
} # Flags (0x01, 0x05, 0x00), Model (0x0b, 0x99, 0x99) - unknown model ID, MAC (0x0a, 0x70, 0xd6, 0xc2, 0x97, 0xba, 0xcc)
93102

94103
BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak(
95104
name="ShellyPlus2PM-C049EF8873E8",
@@ -151,6 +160,46 @@
151160
tx_power=-127,
152161
)
153162

163+
BLE_DISCOVERY_INFO_MAC_IN_MANUFACTURER_DATA = BluetoothServiceInfoBleak(
164+
name="CC:BA:97:C2:D6:72", # BLE address as name (newer devices)
165+
address="CC:BA:97:C2:D6:72", # BLE address may differ from device MAC
166+
rssi=-32,
167+
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC,
168+
service_uuids=[],
169+
service_data={},
170+
source="local",
171+
device=generate_ble_device(
172+
address="CC:BA:97:C2:D6:72",
173+
name="CC:BA:97:C2:D6:72",
174+
),
175+
advertisement=generate_advertisement_data(
176+
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC,
177+
),
178+
time=0,
179+
connectable=True,
180+
tx_power=-127,
181+
)
182+
183+
BLE_DISCOVERY_INFO_MAC_UNKNOWN_MODEL = BluetoothServiceInfoBleak(
184+
name="CC:BA:97:C2:D6:72", # BLE address as name (newer devices)
185+
address="CC:BA:97:C2:D6:72", # BLE address may differ from device MAC
186+
rssi=-32,
187+
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC_UNKNOWN_MODEL,
188+
service_uuids=[],
189+
service_data={},
190+
source="local",
191+
device=generate_ble_device(
192+
address="CC:BA:97:C2:D6:72",
193+
name="CC:BA:97:C2:D6:72",
194+
),
195+
advertisement=generate_advertisement_data(
196+
manufacturer_data=BLE_MANUFACTURER_DATA_WITH_MAC_UNKNOWN_MODEL,
197+
),
198+
time=0,
199+
connectable=True,
200+
tx_power=-127,
201+
)
202+
154203
BLE_DISCOVERY_INFO_NO_DEVICE = BluetoothServiceInfoBleak(
155204
name="ShellyPlus2PM-C049EF8873E8",
156205
address="00:00:00:00:00:00", # Invalid address that won't be found
@@ -2057,6 +2106,53 @@ async def test_bluetooth_discovery_invalid_name(
20572106
assert result["reason"] == "invalid_discovery_info"
20582107

20592108

2109+
@pytest.mark.usefixtures("mock_zeroconf")
2110+
async def test_bluetooth_discovery_mac_in_manufacturer_data(
2111+
hass: HomeAssistant,
2112+
) -> None:
2113+
"""Test bluetooth discovery with MAC in manufacturer data (newer devices)."""
2114+
# Inject BLE device so it's available in the bluetooth scanner
2115+
inject_bluetooth_service_info_bleak(
2116+
hass, BLE_DISCOVERY_INFO_MAC_IN_MANUFACTURER_DATA
2117+
)
2118+
2119+
result = await hass.config_entries.flow.async_init(
2120+
DOMAIN,
2121+
data=BLE_DISCOVERY_INFO_MAC_IN_MANUFACTURER_DATA,
2122+
context={"source": config_entries.SOURCE_BLUETOOTH},
2123+
)
2124+
2125+
# Should successfully extract MAC from manufacturer data
2126+
assert result["type"] is FlowResultType.FORM
2127+
assert result["step_id"] == "bluetooth_confirm"
2128+
# MAC from manufacturer data: 70d6c297bacc (reversed) = CC:BA:97:C2:D6:70 = CCBA97C2D670
2129+
# Model ID 0x1030 = Shelly 1 Mini Gen4
2130+
# Device name should use model name from model ID: Shelly1MiniGen4-<MAC>
2131+
assert result["description_placeholders"]["name"] == "Shelly1MiniGen4-CCBA97C2D670"
2132+
2133+
2134+
@pytest.mark.usefixtures("mock_zeroconf")
2135+
async def test_bluetooth_discovery_mac_unknown_model(
2136+
hass: HomeAssistant,
2137+
) -> None:
2138+
"""Test bluetooth discovery with MAC but unknown model ID."""
2139+
# Inject BLE device so it's available in the bluetooth scanner
2140+
inject_bluetooth_service_info_bleak(hass, BLE_DISCOVERY_INFO_MAC_UNKNOWN_MODEL)
2141+
2142+
result = await hass.config_entries.flow.async_init(
2143+
DOMAIN,
2144+
data=BLE_DISCOVERY_INFO_MAC_UNKNOWN_MODEL,
2145+
context={"source": config_entries.SOURCE_BLUETOOTH},
2146+
)
2147+
2148+
# Should successfully extract MAC from manufacturer data
2149+
assert result["type"] is FlowResultType.FORM
2150+
assert result["step_id"] == "bluetooth_confirm"
2151+
# MAC from manufacturer data: 70d6c297bacc (reversed) = CC:BA:97:C2:D6:70 = CCBA97C2D670
2152+
# Model ID 0x9999 is unknown - should fall back to generic "Shelly-<MAC>"
2153+
assert result["description_placeholders"]["name"] == "Shelly-CCBA97C2D670"
2154+
2155+
20602156
@pytest.mark.usefixtures("mock_rpc_device", "mock_zeroconf")
20612157
async def test_bluetooth_discovery_already_configured(
20622158
hass: HomeAssistant,

0 commit comments

Comments
 (0)