mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 09:47:13 +00: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:
parent
df3688ef08
commit
20ce879471
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1178,6 +1178,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
|
||||||
/homeassistant/components/private_ble_device/ @Jc2k
|
/homeassistant/components/private_ble_device/ @Jc2k
|
||||||
/tests/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
|
/homeassistant/components/profiler/ @bdraco
|
||||||
/tests/components/profiler/ @bdraco
|
/tests/components/profiler/ @bdraco
|
||||||
/homeassistant/components/progettihwsw/ @ardaseremet
|
/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",
|
"domain": "oralb",
|
||||||
"manufacturer_id": 220,
|
"manufacturer_id": 220,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"connectable": True,
|
||||||
|
"domain": "probe_plus",
|
||||||
|
"local_name": "FM2*",
|
||||||
|
"manufacturer_id": 36606,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"connectable": False,
|
"connectable": False,
|
||||||
"domain": "qingping",
|
"domain": "qingping",
|
||||||
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -487,6 +487,7 @@ FLOWS = {
|
|||||||
"powerfox",
|
"powerfox",
|
||||||
"powerwall",
|
"powerwall",
|
||||||
"private_ble_device",
|
"private_ble_device",
|
||||||
|
"probe_plus",
|
||||||
"profiler",
|
"profiler",
|
||||||
"progettihwsw",
|
"progettihwsw",
|
||||||
"prosegur",
|
"prosegur",
|
||||||
|
@ -5048,6 +5048,12 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"iot_class": "local_push"
|
"iot_class": "local_push"
|
||||||
},
|
},
|
||||||
|
"probe_plus": {
|
||||||
|
"name": "Probe Plus",
|
||||||
|
"integration_type": "device",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "local_push"
|
||||||
|
},
|
||||||
"profiler": {
|
"profiler": {
|
||||||
"name": "Profiler",
|
"name": "Profiler",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
|
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@ -2244,6 +2244,9 @@ pyplaato==0.0.19
|
|||||||
# homeassistant.components.point
|
# homeassistant.components.point
|
||||||
pypoint==3.0.0
|
pypoint==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.probe_plus
|
||||||
|
pyprobeplus==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.profiler
|
# homeassistant.components.profiler
|
||||||
pyprof2calltree==1.4.5
|
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
|
# homeassistant.components.point
|
||||||
pypoint==3.0.0
|
pypoint==3.0.0
|
||||||
|
|
||||||
|
# homeassistant.components.probe_plus
|
||||||
|
pyprobeplus==1.0.0
|
||||||
|
|
||||||
# homeassistant.components.profiler
|
# homeassistant.components.profiler
|
||||||
pyprof2calltree==1.4.5
|
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"
|
Loading…
x
Reference in New Issue
Block a user