Skip to content

Commit

Permalink
Add Mopeka integration (#86500)
Browse files Browse the repository at this point in the history
* Add Mopeka integration

Mopeka makes BLE propane tank monitors

* cover

* wip

* wip

* bump lib

* strip binary sensor

* all sensors

* all sensors

* update tests

* change quality

* change quality

* adjust

* integration_type, strict-typing
  • Loading branch information
bdraco authored Jan 25, 2023
1 parent c5c7bb3 commit 03a8dcf
Show file tree
Hide file tree
Showing 19 changed files with 712 additions and 0 deletions.
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ homeassistant.components.mjpeg.*
homeassistant.components.modbus.*
homeassistant.components.modem_callerid.*
homeassistant.components.moon.*
homeassistant.components.mopeka.*
homeassistant.components.mqtt.*
homeassistant.components.mysensors.*
homeassistant.components.nam.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,8 @@ build.json @home-assistant/supervisor
/tests/components/monoprice/ @etsinko @OnFreund
/homeassistant/components/moon/ @fabaff @frenck
/tests/components/moon/ @fabaff @frenck
/homeassistant/components/mopeka/ @bdraco
/tests/components/mopeka/ @bdraco
/homeassistant/components/motion_blinds/ @starkillerOG
/tests/components/motion_blinds/ @starkillerOG
/homeassistant/components/motioneye/ @dermotduffy
Expand Down
49 changes: 49 additions & 0 deletions homeassistant/components/mopeka/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""The Mopeka integration."""
from __future__ import annotations

import logging

from mopeka_iot_ble import MopekaIOTBluetoothDeviceData

from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

from .const import DOMAIN

PLATFORMS: list[Platform] = [Platform.SENSOR]

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Mopeka BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = MopekaIOTBluetoothDeviceData()
coordinator = hass.data.setdefault(DOMAIN, {})[
entry.entry_id
] = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.PASSIVE,
update_method=data.update,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe
return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)

return unload_ok
94 changes: 94 additions & 0 deletions homeassistant/components/mopeka/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Config flow for mopeka integration."""
from __future__ import annotations

from typing import Any

from mopeka_iot_ble import MopekaIOTBluetoothDeviceData as DeviceData
import voluptuous as vol

from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_ADDRESS
from homeassistant.data_entry_flow import FlowResult

from .const import DOMAIN


class MopekaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for mopeka."""

VERSION = 1

def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_device: DeviceData | None = None
self._discovered_devices: dict[str, str] = {}

async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> FlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
device = DeviceData()
if not device.supported(discovery_info):
return self.async_abort(reason="not_supported")
self._discovery_info = discovery_info
self._discovered_device = device
return await self.async_step_bluetooth_confirm()

async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm discovery."""
assert self._discovered_device is not None
device = self._discovered_device
assert self._discovery_info is not None
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
return self.async_create_entry(title=title, data={})

self._set_confirm_only()
placeholders = {"name": title}
self.context["title_placeholders"] = placeholders
return self.async_show_form(
step_id="bluetooth_confirm", description_placeholders=placeholders
)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""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()
return self.async_create_entry(
title=self._discovered_devices[address], data={}
)

current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
continue
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
device.title or device.get_device_name() or discovery_info.name
)

if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")

return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
),
)
3 changes: 3 additions & 0 deletions homeassistant/components/mopeka/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Mopeka integration."""

DOMAIN = "mopeka"
15 changes: 15 additions & 0 deletions homeassistant/components/mopeka/device.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Support for Mopeka devices."""
from __future__ import annotations

from mopeka_iot_ble import DeviceKey

from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothEntityKey,
)


def device_key_to_bluetooth_entity_key(
device_key: DeviceKey,
) -> PassiveBluetoothEntityKey:
"""Convert a device key to an entity key."""
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)
25 changes: 25 additions & 0 deletions homeassistant/components/mopeka/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"domain": "mopeka",
"name": "Mopeka",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/mopeka",
"bluetooth": [
{
"service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb",
"manufacturer_id": 89,
"manufacturer_data_start": [3],
"connectable": false
},
{
"service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb",
"manufacturer_id": 89,
"manufacturer_data_start": [8],
"connectable": false
}
],
"requirements": ["mopeka_iot_ble==0.4.0"],
"dependencies": ["bluetooth_adapters"],
"codeowners": ["@bdraco"],
"iot_class": "local_push",
"integration_type": "device"
}
140 changes: 140 additions & 0 deletions homeassistant/components/mopeka/sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Support for Mopeka sensors."""
from __future__ import annotations

from mopeka_iot_ble import SensorUpdate

from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfLength,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info

from .const import DOMAIN
from .device import device_key_to_bluetooth_entity_key

SENSOR_DESCRIPTIONS = {
"battery": SensorEntityDescription(
key="battery",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
"battery_voltage": SensorEntityDescription(
key="battery_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
"tank_level": SensorEntityDescription(
key="tank_level",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
),
"signal_strength": SensorEntityDescription(
key="signal_strength",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
"reading_quality": SensorEntityDescription(
key="reading_quality",
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
"temperature": SensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
"accelerometer_x": SensorEntityDescription(
key="accelerometer_x",
entity_category=EntityCategory.DIAGNOSTIC,
),
"accelerometer_y": SensorEntityDescription(
key="accelerometer_y",
entity_category=EntityCategory.DIAGNOSTIC,
),
}


def sensor_update_to_bluetooth_data_update(
sensor_update: SensorUpdate,
) -> PassiveBluetoothDataUpdate:
"""Convert a sensor update to a bluetooth data update."""
return PassiveBluetoothDataUpdate(
devices={
device_id: sensor_device_info_to_hass_device_info(device_info)
for device_id, device_info in sensor_update.devices.items()
},
entity_descriptions={
device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
device_key.key
]
for device_key in sensor_update.entity_descriptions
if device_key.key in SENSOR_DESCRIPTIONS
},
entity_data={
device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
for device_key, sensor_values in sensor_update.entity_values.items()
},
entity_names={
device_key_to_bluetooth_entity_key(device_key): sensor_values.name
for device_key, sensor_values in sensor_update.entity_values.items()
},
)


async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Mopeka BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
MopekaBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))


class MopekaBluetoothSensorEntity(
PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]],
SensorEntity,
):
"""Representation of a Mopeka sensor."""

@property
def native_value(self) -> int | float | None:
"""Return the native value."""
return self.processor.entity_data.get(self.entity_key)
22 changes: 22 additions & 0 deletions homeassistant/components/mopeka/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"config": {
"flow_title": "[%key:component::bluetooth::config::flow_title%]",
"step": {
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:component::bluetooth::config::step::user::data::address%]"
}
},
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
}
},
"abort": {
"not_supported": "Device not supported",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
}
}
18 changes: 18 additions & 0 deletions homeassistant/generated/bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,24 @@
"domain": "moat",
"local_name": "Moat_S*",
},
{
"connectable": False,
"domain": "mopeka",
"manufacturer_data_start": [
3,
],
"manufacturer_id": 89,
"service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb",
},
{
"connectable": False,
"domain": "mopeka",
"manufacturer_data_start": [
8,
],
"manufacturer_id": 89,
"service_uuid": "0000fee5-0000-1000-8000-00805f9b34fb",
},
{
"domain": "oralb",
"manufacturer_id": 220,
Expand Down
Loading

0 comments on commit 03a8dcf

Please sign in to comment.