mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
Add new Probe Plus integration (#143424)
* Add probe_plus integration * Changes for quality scale * sentence-casing * Update homeassistant/components/probe_plus/config_flow.py Co-authored-by: Erwin Douna <e.douna@gmail.com> * Update homeassistant/components/probe_plus/config_flow.py Co-authored-by: Erwin Douna <e.douna@gmail.com> * Update tests/components/probe_plus/test_config_flow.py Co-authored-by: Erwin Douna <e.douna@gmail.com> * Update tests/components/probe_plus/test_config_flow.py Co-authored-by: Erwin Douna <e.douna@gmail.com> * remove version from configflow * remove address var from async_step_bluetooth_confirm * move timedelta to SCAN_INTERVAL in coordinator * update tests * updates from review * add voltage device class * remove unused logger * remove names * update tests * Update config flow tests * Update unit tests * Reorder successful tests * Update config entry typing * Remove icons * ruff * Update async_add_entities logic Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com> * sensor platform formatting --------- Co-authored-by: Erwin Douna <e.douna@gmail.com> Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1178,6 +1178,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||
/homeassistant/components/private_ble_device/ @Jc2k
|
||||
/tests/components/private_ble_device/ @Jc2k
|
||||
/homeassistant/components/probe_plus/ @pantherale0
|
||||
/tests/components/probe_plus/ @pantherale0
|
||||
/homeassistant/components/profiler/ @bdraco
|
||||
/tests/components/profiler/ @bdraco
|
||||
/homeassistant/components/progettihwsw/ @ardaseremet
|
||||
|
24
homeassistant/components/probe_plus/__init__.py
Normal file
24
homeassistant/components/probe_plus/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""The Probe Plus integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import ProbePlusConfigEntry, ProbePlusDataUpdateCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ProbePlusConfigEntry) -> bool:
|
||||
"""Set up Probe Plus from a config entry."""
|
||||
coordinator = ProbePlusDataUpdateCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ProbePlusConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
125
homeassistant/components/probe_plus/config_flow.py
Normal file
125
homeassistant/components/probe_plus/config_flow.py
Normal file
@ -0,0 +1,125 @@
|
||||
"""Config flow for probe_plus integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothServiceInfo,
|
||||
async_discovered_service_info,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class Discovery:
|
||||
"""Represents a discovered Bluetooth device.
|
||||
|
||||
Attributes:
|
||||
title: The name or title of the discovered device.
|
||||
discovery_info: Information about the discovered device.
|
||||
|
||||
"""
|
||||
|
||||
title: str
|
||||
discovery_info: BluetoothServiceInfo
|
||||
|
||||
|
||||
def title(discovery_info: BluetoothServiceInfo) -> str:
|
||||
"""Return a title for the discovered device."""
|
||||
return f"{discovery_info.name} {discovery_info.address}"
|
||||
|
||||
|
||||
class ProbeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for BT Probe."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovered_devices: dict[str, Discovery] = {}
|
||||
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth discovery step."""
|
||||
_LOGGER.debug("Discovered BT device: %s", discovery_info)
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.context["title_placeholders"] = {"name": title(discovery_info)}
|
||||
self._discovered_devices[discovery_info.address] = Discovery(
|
||||
title(discovery_info), discovery_info
|
||||
)
|
||||
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
async def async_step_bluetooth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the bluetooth confirmation step."""
|
||||
if user_input is not None:
|
||||
assert self.unique_id
|
||||
self._abort_if_unique_id_configured()
|
||||
discovery = self._discovered_devices[self.unique_id]
|
||||
return self.async_create_entry(
|
||||
title=discovery.title,
|
||||
data={
|
||||
CONF_ADDRESS: discovery.discovery_info.address,
|
||||
},
|
||||
)
|
||||
self._set_confirm_only()
|
||||
assert self.unique_id
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
description_placeholders={
|
||||
"name": title(self._discovered_devices[self.unique_id].discovery_info)
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the user step to pick discovered device."""
|
||||
if user_input is not None:
|
||||
address = user_input[CONF_ADDRESS]
|
||||
await self.async_set_unique_id(address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
discovery = self._discovered_devices[address]
|
||||
return self.async_create_entry(
|
||||
title=discovery.title,
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
continue
|
||||
|
||||
self._discovered_devices[address] = Discovery(
|
||||
title(discovery_info), discovery_info
|
||||
)
|
||||
|
||||
if not self._discovered_devices:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
titles = {
|
||||
address: discovery.title
|
||||
for (address, discovery) in self._discovered_devices.items()
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(titles),
|
||||
}
|
||||
),
|
||||
)
|
3
homeassistant/components/probe_plus/const.py
Normal file
3
homeassistant/components/probe_plus/const.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Constants for the Probe Plus integration."""
|
||||
|
||||
DOMAIN = "probe_plus"
|
68
homeassistant/components/probe_plus/coordinator.py
Normal file
68
homeassistant/components/probe_plus/coordinator.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Coordinator for the probe_plus integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pyprobeplus import ProbePlusDevice
|
||||
from pyprobeplus.exceptions import ProbePlusDeviceNotFound, ProbePlusError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
type ProbePlusConfigEntry = ConfigEntry[ProbePlusDataUpdateCoordinator]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class ProbePlusDataUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Coordinator to manage data updates for a probe device.
|
||||
|
||||
This class handles the communication with Probe Plus devices.
|
||||
|
||||
Data is updated by the device itself.
|
||||
"""
|
||||
|
||||
config_entry: ProbePlusConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ProbePlusConfigEntry) -> None:
|
||||
"""Initialize coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name="ProbePlusDataUpdateCoordinator",
|
||||
update_interval=SCAN_INTERVAL,
|
||||
config_entry=entry,
|
||||
)
|
||||
|
||||
self.device: ProbePlusDevice = ProbePlusDevice(
|
||||
address_or_ble_device=entry.data[CONF_ADDRESS],
|
||||
name=entry.title,
|
||||
notify_callback=self.async_update_listeners,
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Connect to the Probe Plus device on a set interval.
|
||||
|
||||
This method is called periodically to reconnect to the device
|
||||
Data updates are handled by the device itself.
|
||||
"""
|
||||
# Already connected, no need to update any data as the device streams this.
|
||||
if self.device.connected:
|
||||
return
|
||||
|
||||
# Probe is not connected, try to connect
|
||||
try:
|
||||
await self.device.connect()
|
||||
except (ProbePlusError, ProbePlusDeviceNotFound, TimeoutError) as e:
|
||||
_LOGGER.debug(
|
||||
"Could not connect to scale: %s, Error: %s",
|
||||
self.config_entry.data[CONF_ADDRESS],
|
||||
e,
|
||||
)
|
||||
self.device.device_disconnected_handler(notify=False)
|
||||
return
|
54
homeassistant/components/probe_plus/entity.py
Normal file
54
homeassistant/components/probe_plus/entity.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""Probe Plus base entity type."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyprobeplus import ProbePlusDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_BLUETOOTH,
|
||||
DeviceInfo,
|
||||
format_mac,
|
||||
)
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import ProbePlusDataUpdateCoordinator
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProbePlusEntity(CoordinatorEntity[ProbePlusDataUpdateCoordinator]):
|
||||
"""Base class for Probe Plus entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: ProbePlusDataUpdateCoordinator,
|
||||
entity_description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = entity_description
|
||||
|
||||
# Set the unique ID for the entity
|
||||
self._attr_unique_id = (
|
||||
f"{format_mac(coordinator.device.mac)}_{entity_description.key}"
|
||||
)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(coordinator.device.mac))},
|
||||
name=coordinator.device.name,
|
||||
manufacturer="Probe Plus",
|
||||
suggested_area="Kitchen",
|
||||
connections={(CONNECTION_BLUETOOTH, coordinator.device.mac)},
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the entity is available."""
|
||||
return super().available and self.coordinator.device.connected
|
||||
|
||||
@property
|
||||
def device(self) -> ProbePlusDevice:
|
||||
"""Return the device associated with this entity."""
|
||||
return self.coordinator.device
|
9
homeassistant/components/probe_plus/icons.json
Normal file
9
homeassistant/components/probe_plus/icons.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"probe_temperature": {
|
||||
"default": "mdi:thermometer-bluetooth"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
19
homeassistant/components/probe_plus/manifest.json
Normal file
19
homeassistant/components/probe_plus/manifest.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"domain": "probe_plus",
|
||||
"name": "Probe Plus",
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": true,
|
||||
"manufacturer_id": 36606,
|
||||
"local_name": "FM2*"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@pantherale0"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/probe_plus",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyprobeplus==1.0.0"]
|
||||
}
|
100
homeassistant/components/probe_plus/quality_scale.yaml
Normal file
100
homeassistant/components/probe_plus/quality_scale.yaml
Normal file
@ -0,0 +1,100 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
No explicit event subscriptions.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device is expected to be offline most of the time, but needs to connect quickly once available.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom actions are defined.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: |
|
||||
Handled by coordinator.
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
No authentication required.
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
No IP discovery.
|
||||
discovery:
|
||||
status: done
|
||||
comment: |
|
||||
The integration uses Bluetooth discovery to find devices.
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
No custom exceptions are defined.
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
No reconfiguration flow is needed as the only thing that could be changed is the MAC, which is already hardcoded on the device itself.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No repair issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
The device itself is the integration.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
No web session is used.
|
||||
strict-typing: todo
|
106
homeassistant/components/probe_plus/sensor.py
Normal file
106
homeassistant/components/probe_plus/sensor.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Support for Probe Plus BLE sensors."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
RestoreSensor,
|
||||
SensorDeviceClass,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ProbePlusConfigEntry, ProbePlusDevice
|
||||
from .entity import ProbePlusEntity
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class ProbePlusSensorEntityDescription(SensorEntityDescription):
|
||||
"""Description for Probe Plus sensor entities."""
|
||||
|
||||
value_fn: Callable[[ProbePlusDevice], int | float | None]
|
||||
|
||||
|
||||
SENSOR_DESCRIPTIONS: tuple[ProbePlusSensorEntityDescription, ...] = (
|
||||
ProbePlusSensorEntityDescription(
|
||||
key="probe_temperature",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda device: device.device_state.probe_temperature,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
ProbePlusSensorEntityDescription(
|
||||
key="probe_battery",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda device: device.device_state.probe_battery,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
),
|
||||
ProbePlusSensorEntityDescription(
|
||||
key="relay_battery",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
value_fn=lambda device: device.device_state.relay_battery,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
),
|
||||
ProbePlusSensorEntityDescription(
|
||||
key="probe_rssi",
|
||||
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda device: device.device_state.probe_rssi,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ProbePlusSensorEntityDescription(
|
||||
key="relay_voltage",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
value_fn=lambda device: device.device_state.relay_voltage,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ProbePlusSensorEntityDescription(
|
||||
key="probe_voltage",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
value_fn=lambda device: device.device_state.probe_voltage,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ProbePlusConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Probe Plus sensors."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(ProbeSensor(coordinator, desc) for desc in SENSOR_DESCRIPTIONS)
|
||||
|
||||
|
||||
class ProbeSensor(ProbePlusEntity, RestoreSensor):
|
||||
"""Representation of a Probe Plus sensor."""
|
||||
|
||||
entity_description: ProbePlusSensorEntityDescription
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | float | None:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self.device)
|
49
homeassistant/components/probe_plus/strings.json
Normal file
49
homeassistant/components/probe_plus/strings.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"error": {
|
||||
"device_not_found": "Device could not be found.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
|
||||
},
|
||||
"user": {
|
||||
"description": "[%key:component::bluetooth::config::step::user::description%]",
|
||||
"data": {
|
||||
"address": "[%key:common::config_flow::data::device%]"
|
||||
},
|
||||
"data_description": {
|
||||
"address": "Select BLE probe you want to set up"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"probe_battery": {
|
||||
"name": "Probe battery"
|
||||
},
|
||||
"probe_temperature": {
|
||||
"name": "Probe temperature"
|
||||
},
|
||||
"probe_rssi": {
|
||||
"name": "Probe RSSI"
|
||||
},
|
||||
"probe_voltage": {
|
||||
"name": "Probe voltage"
|
||||
},
|
||||
"relay_battery": {
|
||||
"name": "Relay battery"
|
||||
},
|
||||
"relay_voltage": {
|
||||
"name": "Relay voltage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
6
homeassistant/generated/bluetooth.py
generated
6
homeassistant/generated/bluetooth.py
generated
@ -593,6 +593,12 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"domain": "oralb",
|
||||
"manufacturer_id": 220,
|
||||
},
|
||||
{
|
||||
"connectable": True,
|
||||
"domain": "probe_plus",
|
||||
"local_name": "FM2*",
|
||||
"manufacturer_id": 36606,
|
||||
},
|
||||
{
|
||||
"connectable": False,
|
||||
"domain": "qingping",
|
||||
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -487,6 +487,7 @@ FLOWS = {
|
||||
"powerfox",
|
||||
"powerwall",
|
||||
"private_ble_device",
|
||||
"probe_plus",
|
||||
"profiler",
|
||||
"progettihwsw",
|
||||
"prosegur",
|
||||
|
@ -5048,6 +5048,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"probe_plus": {
|
||||
"name": "Probe Plus",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"profiler": {
|
||||
"name": "Profiler",
|
||||
"integration_type": "hub",
|
||||
|
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@ -2244,6 +2244,9 @@ pyplaato==0.0.19
|
||||
# homeassistant.components.point
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.0.0
|
||||
|
||||
# homeassistant.components.profiler
|
||||
pyprof2calltree==1.4.5
|
||||
|
||||
|
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@ -1838,6 +1838,9 @@ pyplaato==0.0.19
|
||||
# homeassistant.components.point
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.0.0
|
||||
|
||||
# homeassistant.components.profiler
|
||||
pyprof2calltree==1.4.5
|
||||
|
||||
|
14
tests/components/probe_plus/__init__.py
Normal file
14
tests/components/probe_plus/__init__.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""Tests for the Probe Plus integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Set up the Probe Plus integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
60
tests/components/probe_plus/conftest.py
Normal file
60
tests/components/probe_plus/conftest.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Common fixtures for the Probe Plus tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pyprobeplus.parser import ParserBase, ProbePlusData
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.probe_plus.const import DOMAIN
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.probe_plus.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
title="FM210 aa:bb:cc:dd:ee:ff",
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
data={
|
||||
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
|
||||
},
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_probe_plus() -> MagicMock:
|
||||
"""Mock the Probe Plus device."""
|
||||
with patch(
|
||||
"homeassistant.components.probe_plus.coordinator.ProbePlusDevice",
|
||||
autospec=True,
|
||||
) as mock_device:
|
||||
device = mock_device.return_value
|
||||
device.connected = True
|
||||
device.name = "FM210 aa:bb:cc:dd:ee:ff"
|
||||
mock_state = ParserBase()
|
||||
mock_state.state = ProbePlusData(
|
||||
relay_battery=50,
|
||||
probe_battery=50,
|
||||
probe_temperature=25.0,
|
||||
probe_rssi=200,
|
||||
probe_voltage=3.7,
|
||||
relay_status=1,
|
||||
relay_voltage=9.0,
|
||||
)
|
||||
device._device_state = mock_state
|
||||
yield device
|
133
tests/components/probe_plus/test_config_flow.py
Normal file
133
tests/components/probe_plus/test_config_flow.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""Test the config flow for the Probe Plus."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.probe_plus.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
service_info = BluetoothServiceInfo(
|
||||
name="FM210",
|
||||
address="aa:bb:cc:dd:ee:ff",
|
||||
rssi=-63,
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
source="local",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_discovered_service_info() -> Generator[AsyncMock]:
|
||||
"""Override getting Bluetooth service info."""
|
||||
with patch(
|
||||
"homeassistant.components.probe_plus.config_flow.async_discovered_service_info",
|
||||
return_value=[service_info],
|
||||
) as mock_discovered_service_info:
|
||||
yield mock_discovered_service_info
|
||||
|
||||
|
||||
async def test_user_config_flow_creates_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_discovered_service_info: AsyncMock,
|
||||
) -> None:
|
||||
"""Test the user configuration flow successfully creates a config entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff"
|
||||
assert result["title"] == "FM210 aa:bb:cc:dd:ee:ff"
|
||||
assert result["data"] == {CONF_ADDRESS: "aa:bb:cc:dd:ee:ff"}
|
||||
|
||||
|
||||
async def test_user_flow_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_discovered_service_info: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test that the user flow aborts when the entry is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
# this aborts with no devices found as the config flow
|
||||
# already checks for existing config entries when validating the discovered devices
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
async def test_bluetooth_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_discovered_service_info: AsyncMock,
|
||||
) -> None:
|
||||
"""Test we can discover a device."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "FM210 aa:bb:cc:dd:ee:ff"
|
||||
assert result["result"].unique_id == "aa:bb:cc:dd:ee:ff"
|
||||
assert result["data"] == {
|
||||
CONF_ADDRESS: service_info.address,
|
||||
}
|
||||
|
||||
|
||||
async def test_already_configured_bluetooth_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Ensure configure device is not discovered again."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_no_bluetooth_devices(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_discovered_service_info: AsyncMock,
|
||||
) -> None:
|
||||
"""Test flow aborts on unsupported device."""
|
||||
mock_discovered_service_info.return_value = []
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "no_devices_found"
|
Reference in New Issue
Block a user