Skip to content

Commit 4b69543

Browse files
rajlaudCopilotjoostlek
authored
Add support for Victron bluetooth low energy devices (home-assistant#148043)
Co-authored-by: Copilot <[email protected]> Co-authored-by: Joostlek <[email protected]>
1 parent 97ef4a3 commit 4b69543

File tree

20 files changed

+3395
-5
lines changed

20 files changed

+3395
-5
lines changed

CODEOWNERS

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

homeassistant/brands/victron.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"domain": "victron",
3+
"name": "Victron",
4+
"integrations": ["victron_ble", "victron_remote_monitoring"]
5+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""The Victron Bluetooth Low Energy integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from victron_ble_ha_parser import VictronBluetoothDeviceData
8+
9+
from homeassistant.components.bluetooth import (
10+
BluetoothScanningMode,
11+
async_rediscover_address,
12+
)
13+
from homeassistant.components.bluetooth.passive_update_processor import (
14+
PassiveBluetoothProcessorCoordinator,
15+
)
16+
from homeassistant.config_entries import ConfigEntry
17+
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
18+
from homeassistant.core import HomeAssistant
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
23+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
24+
"""Set up Victron BLE device from a config entry."""
25+
address = entry.unique_id
26+
assert address is not None
27+
key = entry.data[CONF_ACCESS_TOKEN]
28+
data = VictronBluetoothDeviceData(key)
29+
coordinator = PassiveBluetoothProcessorCoordinator(
30+
hass,
31+
_LOGGER,
32+
address=address,
33+
mode=BluetoothScanningMode.ACTIVE,
34+
update_method=data.update,
35+
)
36+
entry.runtime_data = coordinator
37+
38+
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
39+
entry.async_on_unload(coordinator.async_start())
40+
41+
return True
42+
43+
44+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
45+
"""Unload a config entry."""
46+
47+
unload_ok = await hass.config_entries.async_unload_platforms(
48+
entry, [Platform.SENSOR]
49+
)
50+
51+
if unload_ok:
52+
async_rediscover_address(hass, entry.entry_id)
53+
54+
return unload_ok
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Config flow for Victron Bluetooth Low Energy integration."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any
7+
8+
from victron_ble_ha_parser import VictronBluetoothDeviceData
9+
import voluptuous as vol
10+
11+
from homeassistant.components.bluetooth import (
12+
BluetoothServiceInfoBleak,
13+
async_discovered_service_info,
14+
)
15+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
16+
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
17+
18+
from .const import DOMAIN, VICTRON_IDENTIFIER
19+
20+
_LOGGER = logging.getLogger(__name__)
21+
22+
STEP_ACCESS_TOKEN_DATA_SCHEMA = vol.Schema(
23+
{
24+
vol.Required(CONF_ACCESS_TOKEN): str,
25+
}
26+
)
27+
28+
29+
class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN):
30+
"""Handle a config flow for Victron Bluetooth Low Energy."""
31+
32+
VERSION = 1
33+
34+
def __init__(self) -> None:
35+
"""Initialize the config flow."""
36+
self._discovered_device: str | None = None
37+
self._discovered_devices: dict[str, str] = {}
38+
self._discovered_devices_info: dict[str, BluetoothServiceInfoBleak] = {}
39+
40+
async def async_step_bluetooth(
41+
self, discovery_info: BluetoothServiceInfoBleak
42+
) -> ConfigFlowResult:
43+
"""Handle the bluetooth discovery step."""
44+
_LOGGER.debug("async_step_bluetooth: %s", discovery_info.address)
45+
await self.async_set_unique_id(discovery_info.address)
46+
self._abort_if_unique_id_configured()
47+
device = VictronBluetoothDeviceData()
48+
if not device.supported(discovery_info):
49+
_LOGGER.debug("device %s not supported", discovery_info.address)
50+
return self.async_abort(reason="not_supported")
51+
52+
self._discovered_device = discovery_info.address
53+
self._discovered_devices_info[discovery_info.address] = discovery_info
54+
self._discovered_devices[discovery_info.address] = discovery_info.name
55+
56+
self.context["title_placeholders"] = {"title": discovery_info.name}
57+
58+
return await self.async_step_access_token()
59+
60+
async def async_step_access_token(
61+
self, user_input: dict[str, Any] | None = None
62+
) -> ConfigFlowResult:
63+
"""Handle advertisement key input."""
64+
# should only be called if there are discovered devices
65+
assert self._discovered_device is not None
66+
discovery_info = self._discovered_devices_info[self._discovered_device]
67+
title = discovery_info.name
68+
69+
if user_input is not None:
70+
# see if we can create a device with the access token
71+
device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN])
72+
if device.validate_advertisement_key(
73+
discovery_info.manufacturer_data[VICTRON_IDENTIFIER]
74+
):
75+
return self.async_create_entry(
76+
title=title,
77+
data=user_input,
78+
)
79+
return self.async_abort(reason="invalid_access_token")
80+
81+
return self.async_show_form(
82+
step_id="access_token",
83+
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
84+
description_placeholders={"title": title},
85+
)
86+
87+
async def async_step_user(
88+
self, user_input: dict[str, Any] | None = None
89+
) -> ConfigFlowResult:
90+
"""Handle select a device to set up."""
91+
if user_input is not None:
92+
address = user_input[CONF_ADDRESS]
93+
await self.async_set_unique_id(address, raise_on_progress=False)
94+
self._abort_if_unique_id_configured()
95+
self._discovered_device = address
96+
title = self._discovered_devices_info[address].name
97+
return self.async_show_form(
98+
step_id="access_token",
99+
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
100+
description_placeholders={"title": title},
101+
)
102+
103+
current_addresses = self._async_current_ids()
104+
for discovery_info in async_discovered_service_info(self.hass, False):
105+
address = discovery_info.address
106+
if address in current_addresses or address in self._discovered_devices:
107+
continue
108+
device = VictronBluetoothDeviceData()
109+
if device.supported(discovery_info):
110+
self._discovered_devices_info[address] = discovery_info
111+
self._discovered_devices[address] = discovery_info.name
112+
113+
if len(self._discovered_devices) < 1:
114+
return self.async_abort(reason="no_devices_found")
115+
116+
_LOGGER.debug("Discovered %s devices", len(self._discovered_devices))
117+
118+
return self.async_show_form(
119+
step_id="user",
120+
data_schema=vol.Schema(
121+
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
122+
),
123+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
"""Constants for the Victron Bluetooth Low Energy integration."""
2+
3+
DOMAIN = "victron_ble"
4+
VICTRON_IDENTIFIER = 0x02E1
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"domain": "victron_ble",
3+
"name": "Victron BLE",
4+
"bluetooth": [
5+
{
6+
"connectable": false,
7+
"manufacturer_data_start": [16],
8+
"manufacturer_id": 737
9+
}
10+
],
11+
"codeowners": ["@rajlaud"],
12+
"config_flow": true,
13+
"dependencies": ["bluetooth_adapters"],
14+
"documentation": "https://www.home-assistant.io/integrations/victron_ble",
15+
"integration_type": "device",
16+
"iot_class": "local_push",
17+
"quality_scale": "bronze",
18+
"requirements": ["victron-ble-ha-parser==0.4.9"]
19+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
rules:
2+
# Bronze
3+
action-setup:
4+
status: exempt
5+
comment: |
6+
This integration does not provide additional actions.
7+
appropriate-polling:
8+
status: exempt
9+
comment: |
10+
This integration does not poll.
11+
brands: done
12+
common-modules: done
13+
config-flow-test-coverage: done
14+
config-flow: done
15+
dependency-transparency: done
16+
docs-actions:
17+
status: exempt
18+
comment: |
19+
This integration does not provide additional actions.
20+
docs-high-level-description: done
21+
docs-installation-instructions: done
22+
docs-removal-instructions: done
23+
entity-event-setup: done
24+
entity-unique-id: done
25+
has-entity-name: done
26+
runtime-data: done
27+
test-before-configure: done
28+
test-before-setup:
29+
status: exempt
30+
comment: |
31+
There is nothing to test, the integration just passively receives BLE advertisements.
32+
unique-config-entry: done
33+
# Silver
34+
action-exceptions:
35+
status: exempt
36+
comment: This integration does not provide additional actions.
37+
config-entry-unloading: done
38+
docs-configuration-parameters:
39+
status: exempt
40+
comment: No options to configure
41+
docs-installation-parameters: done
42+
entity-unavailable: done
43+
integration-owner: done
44+
log-when-unavailable: todo
45+
parallel-updates:
46+
status: done
47+
reauthentication-flow:
48+
status: todo
49+
test-coverage: done
50+
# Gold
51+
devices: done
52+
diagnostics: todo
53+
discovery-update-info:
54+
status: exempt
55+
comment: |
56+
This integration does not use IP addresses. Bluetooth MAC addresses do not change.
57+
discovery: done
58+
docs-data-update: done
59+
docs-examples: todo
60+
docs-known-limitations: todo
61+
docs-supported-devices: todo
62+
docs-supported-functions: todo
63+
docs-troubleshooting: todo
64+
docs-use-cases: todo
65+
dynamic-devices:
66+
status: exempt
67+
comment: |
68+
This integration has a fixed single device per instance, and each device needs a user-supplied encryption key to set up.
69+
entity-category: done
70+
entity-device-class: done
71+
entity-disabled-by-default: todo
72+
entity-translations: todo
73+
exception-translations: todo
74+
icon-translations: todo
75+
reconfiguration-flow: todo
76+
repair-issues: todo
77+
stale-devices:
78+
status: exempt
79+
comment: |
80+
This integration has a fixed single device.
81+
82+
# Platinum
83+
async-dependency: todo
84+
inject-websession: todo
85+
strict-typing: todo

0 commit comments

Comments
 (0)