Add support for Mighty Mule MMS100 to Nice G.O. (#127765)

This commit is contained in:
IceBotYT 2024-10-25 11:49:32 -04:00 committed by GitHub
parent 839c884cef
commit 295ae7b4bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 226 additions and 33 deletions

View file

@ -2,6 +2,8 @@
from datetime import timedelta from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "nice_go" DOMAIN = "nice_go"
# Configuration # Configuration
@ -11,3 +13,22 @@ CONF_REFRESH_TOKEN = "refresh_token"
CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time" CONF_REFRESH_TOKEN_CREATION_TIME = "refresh_token_creation_time"
REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30) REFRESH_TOKEN_EXPIRY_TIME = timedelta(days=30)
SUPPORTED_DEVICE_TYPES = {
Platform.LIGHT: ["WallStation"],
Platform.SWITCH: ["WallStation"],
}
KNOWN_UNSUPPORTED_DEVICE_TYPES = {
Platform.LIGHT: ["Mms100"],
Platform.SWITCH: ["Mms100"],
}
UNSUPPORTED_DEVICE_WARNING = (
"Device '%s' has unknown device type '%s', "
"which is not supported by this integration. "
"We try to support it with a cover and event entity, but nothing else. "
"Please create an issue with your device model in additional info"
" at https://github.com/home-assistant/core/issues/new"
"?assignees=&labels=&projects=&template=bug_report.yml"
"&title=New%%20Nice%%20G.O.%%20device%%20type%%20'%s'%%20found"
)

View file

@ -44,13 +44,14 @@ RECONNECT_DELAY = 5
class NiceGODevice: class NiceGODevice:
"""Nice G.O. device dataclass.""" """Nice G.O. device dataclass."""
type: str
id: str id: str
name: str name: str
barrier_status: str barrier_status: str
light_status: bool | None light_status: bool | None
fw_version: str fw_version: str
connected: bool connected: bool
vacation_mode: bool vacation_mode: bool | None
class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]): class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
@ -85,7 +86,9 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
"""Stop reconnecting if hass is stopping.""" """Stop reconnecting if hass is stopping."""
self._hass_stopping = True self._hass_stopping = True
async def _parse_barrier(self, barrier_state: BarrierState) -> NiceGODevice | None: async def _parse_barrier(
self, device_type: str, barrier_state: BarrierState
) -> NiceGODevice | None:
"""Parse barrier data.""" """Parse barrier data."""
device_id = barrier_state.deviceId device_id = barrier_state.deviceId
@ -121,11 +124,15 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
fw_version = barrier_state.reported["deviceFwVersion"] fw_version = barrier_state.reported["deviceFwVersion"]
if barrier_state.connectionState: if barrier_state.connectionState:
connected = barrier_state.connectionState.connected connected = barrier_state.connectionState.connected
elif device_type == "Mms100":
connected = barrier_state.reported.get("radioConnected", 0) == 1
else: else:
connected = False # Assume connected
vacation_mode = barrier_state.reported["vcnMode"] connected = True
vacation_mode = barrier_state.reported.get("vcnMode", None)
return NiceGODevice( return NiceGODevice(
type=device_type,
id=device_id, id=device_id,
name=name, name=name,
barrier_status=barrier_status, barrier_status=barrier_status,
@ -156,7 +163,8 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
barriers = await self.api.get_all_barriers() barriers = await self.api.get_all_barriers()
parsed_barriers = [ parsed_barriers = [
await self._parse_barrier(barrier.state) for barrier in barriers await self._parse_barrier(barrier.type, barrier.state)
for barrier in barriers
] ]
# Parse the barriers and save them in a dictionary # Parse the barriers and save them in a dictionary
@ -226,6 +234,9 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
_LOGGER.debug(data) _LOGGER.debug(data)
raw_data = data["data"]["devicesStatesUpdateFeed"]["item"] raw_data = data["data"]["devicesStatesUpdateFeed"]["item"]
parsed_data = await self._parse_barrier( parsed_data = await self._parse_barrier(
self.data[
raw_data["deviceId"]
].type, # Device type is not sent in device state update, and it can't change, so we just reuse the existing one
BarrierState( BarrierState(
deviceId=raw_data["deviceId"], deviceId=raw_data["deviceId"],
desired=json.loads(raw_data["desired"]), desired=json.loads(raw_data["desired"]),
@ -238,7 +249,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
else None, else None,
version=raw_data["version"], version=raw_data["version"],
timestamp=raw_data["timestamp"], timestamp=raw_data["timestamp"],
) ),
) )
if parsed_data is None: if parsed_data is None:
return return

View file

@ -18,6 +18,10 @@ from . import NiceGOConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .entity import NiceGOEntity from .entity import NiceGOEntity
DEVICE_CLASSES = {
"WallStation": CoverDeviceClass.GARAGE,
"Mms100": CoverDeviceClass.GATE,
}
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
@ -40,7 +44,11 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity):
_attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
_attr_name = None _attr_name = None
_attr_device_class = CoverDeviceClass.GARAGE
@property
def device_class(self) -> CoverDeviceClass:
"""Return the class of this device, from component DEVICE_CLASSES."""
return DEVICE_CLASSES.get(self.data.type, CoverDeviceClass.GARAGE)
@property @property
def is_closed(self) -> bool: def is_closed(self) -> bool:

View file

@ -1,19 +1,28 @@
"""Nice G.O. light.""" """Nice G.O. light."""
import logging
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from aiohttp import ClientError from aiohttp import ClientError
from nice_go import ApiError from nice_go import ApiError
from homeassistant.components.light import ColorMode, LightEntity from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import NiceGOConfigEntry from . import NiceGOConfigEntry
from .const import DOMAIN from .const import (
DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .entity import NiceGOEntity from .entity import NiceGOEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -24,11 +33,20 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data
async_add_entities( entities = []
NiceGOLightEntity(coordinator, device_id, device_data.name)
for device_id, device_data in coordinator.data.items() for device_id, device_data in coordinator.data.items():
if device_data.light_status is not None if device_data.type in SUPPORTED_DEVICE_TYPES[Platform.LIGHT]:
) entities.append(NiceGOLightEntity(coordinator, device_id, device_data.name))
elif device_data.type not in KNOWN_UNSUPPORTED_DEVICE_TYPES[Platform.LIGHT]:
_LOGGER.warning(
UNSUPPORTED_DEVICE_WARNING,
device_data.name,
device_data.type,
device_data.type,
)
async_add_entities(entities)
class NiceGOLightEntity(NiceGOEntity, LightEntity): class NiceGOLightEntity(NiceGOEntity, LightEntity):

View file

@ -3,18 +3,24 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import TYPE_CHECKING, Any
from aiohttp import ClientError from aiohttp import ClientError
from nice_go import ApiError from nice_go import ApiError
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import NiceGOConfigEntry from . import NiceGOConfigEntry
from .const import DOMAIN from .const import (
DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .entity import NiceGOEntity from .entity import NiceGOEntity
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,10 +34,22 @@ async def async_setup_entry(
"""Set up Nice G.O. switch.""" """Set up Nice G.O. switch."""
coordinator = config_entry.runtime_data coordinator = config_entry.runtime_data
async_add_entities( entities = []
NiceGOSwitchEntity(coordinator, device_id, device_data.name)
for device_id, device_data in coordinator.data.items() for device_id, device_data in coordinator.data.items():
) if device_data.type in SUPPORTED_DEVICE_TYPES[Platform.SWITCH]:
entities.append(
NiceGOSwitchEntity(coordinator, device_id, device_data.name)
)
elif device_data.type not in KNOWN_UNSUPPORTED_DEVICE_TYPES[Platform.SWITCH]:
_LOGGER.warning(
UNSUPPORTED_DEVICE_WARNING,
device_data.name,
device_data.type,
device_data.type,
)
async_add_entities(entities)
class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity): class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity):
@ -43,6 +61,8 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return if switch is on.""" """Return if switch is on."""
if TYPE_CHECKING:
assert self.data.vacation_mode is not None
return self.data.vacation_mode return self.data.vacation_mode
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:

View file

@ -52,7 +52,9 @@ def mock_nice_go() -> Generator[AsyncMock]:
attr=barrier["attr"], attr=barrier["attr"],
state=BarrierState( state=BarrierState(
**barrier["state"], **barrier["state"],
connectionState=ConnectionState(**barrier["connectionState"]), connectionState=ConnectionState(**barrier["connectionState"])
if barrier.get("connectionState")
else None,
), ),
api=client, api=client,
) )

View file

@ -63,7 +63,7 @@
}, },
{ {
"id": "3", "id": "3",
"type": "WallStation", "type": "Mms100",
"controlLevel": "Owner", "controlLevel": "Owner",
"attr": [ "attr": [
{ {
@ -79,16 +79,42 @@
"autoDisabled": false, "autoDisabled": false,
"migrationStatus": "DONE", "migrationStatus": "DONE",
"deviceId": "3", "deviceId": "3",
"vcnMode": false,
"deviceFwVersion": "1.2.3.4.5.6", "deviceFwVersion": "1.2.3.4.5.6",
"barrierStatus": "2,100,0,0,-1,0,3,0" "barrierStatus": "1,100,0,0,1,0,0,0",
"radioConnected": 1,
"powerLevel": "LOW"
}, },
"timestamp": null, "timestamp": null,
"version": null "version": null
}, },
"connectionState": { "connectionState": null
"connected": true, },
"updatedTimestamp": "123" {
} "id": "4",
"type": "unknown-device-type",
"controlLevel": "Owner",
"attr": [
{
"key": "organization",
"value": "test_organization"
}
],
"state": {
"deviceId": "4",
"desired": { "key": "value" },
"reported": {
"displayName": "Test Garage 4",
"autoDisabled": false,
"migrationStatus": "DONE",
"deviceId": "4",
"deviceFwVersion": "1.2.3.4.5.6",
"barrierStatus": "1,100,0,0,1,0,0,0",
"radioConnected": 1,
"powerLevel": "LOW"
},
"timestamp": null,
"version": null
},
"connectionState": null
} }
] ]

View file

@ -117,7 +117,7 @@
'name': None, 'name': None,
'options': dict({ 'options': dict({
}), }),
'original_device_class': <CoverDeviceClass.GARAGE: 'garage'>, 'original_device_class': <CoverDeviceClass.GATE: 'gate'>,
'original_icon': None, 'original_icon': None,
'original_name': None, 'original_name': None,
'platform': 'nice_go', 'platform': 'nice_go',
@ -131,7 +131,7 @@
# name: test_covers[cover.test_garage_3-state] # name: test_covers[cover.test_garage_3-state]
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'device_class': 'garage', 'device_class': 'gate',
'friendly_name': 'Test Garage 3', 'friendly_name': 'Test Garage 3',
'supported_features': <CoverEntityFeature: 3>, 'supported_features': <CoverEntityFeature: 3>,
}), }),
@ -140,6 +140,54 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': 'closed', 'state': 'open',
})
# ---
# name: test_covers[cover.test_garage_4-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.test_garage_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <CoverDeviceClass.GARAGE: 'garage'>,
'original_icon': None,
'original_name': None,
'platform': 'nice_go',
'previous_unique_id': None,
'supported_features': <CoverEntityFeature: 3>,
'translation_key': None,
'unique_id': '4',
'unit_of_measurement': None,
})
# ---
# name: test_covers[cover.test_garage_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'garage',
'friendly_name': 'Test Garage 4',
'supported_features': <CoverEntityFeature: 3>,
}),
'context': <ANY>,
'entity_id': 'cover.test_garage_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
}) })
# --- # ---

View file

@ -9,6 +9,7 @@
'id': '1', 'id': '1',
'light_status': True, 'light_status': True,
'name': 'Test Garage 1', 'name': 'Test Garage 1',
'type': 'WallStation',
'vacation_mode': False, 'vacation_mode': False,
}), }),
'2': dict({ '2': dict({
@ -18,16 +19,28 @@
'id': '2', 'id': '2',
'light_status': False, 'light_status': False,
'name': 'Test Garage 2', 'name': 'Test Garage 2',
'type': 'WallStation',
'vacation_mode': True, 'vacation_mode': True,
}), }),
'3': dict({ '3': dict({
'barrier_status': 'closed', 'barrier_status': 'open',
'connected': True, 'connected': True,
'fw_version': '1.2.3.4.5.6', 'fw_version': '1.2.3.4.5.6',
'id': '3', 'id': '3',
'light_status': None, 'light_status': None,
'name': 'Test Garage 3', 'name': 'Test Garage 3',
'vacation_mode': False, 'type': 'Mms100',
'vacation_mode': None,
}),
'4': dict({
'barrier_status': 'open',
'connected': True,
'fw_version': '1.2.3.4.5.6',
'id': '4',
'light_status': None,
'name': 'Test Garage 4',
'type': 'unknown-device-type',
'vacation_mode': None,
}), }),
}), }),
'entry': dict({ 'entry': dict({

View file

@ -347,7 +347,7 @@ async def test_no_connection_state(
} }
) )
assert hass.states.get("cover.test_garage_1").state == "unavailable" assert hass.states.get("cover.test_garage_1").state == "open"
async def test_connection_attempts_exhausted( async def test_connection_attempts_exhausted(

View file

@ -134,3 +134,29 @@ async def test_error(
{ATTR_ENTITY_ID: entity_id}, {ATTR_ENTITY_ID: entity_id},
blocking=True, blocking=True,
) )
async def test_unsupported_device_type(
hass: HomeAssistant,
mock_nice_go: AsyncMock,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that unsupported device types are handled appropriately."""
await setup_integration(hass, mock_config_entry, [Platform.LIGHT])
assert hass.states.get("light.test_garage_4_light") is None
assert (
"Device 'Test Garage 4' has unknown device type 'unknown-device-type'"
in caplog.text
)
assert "which is not supported by this integration" in caplog.text
assert (
"We try to support it with a cover and event entity, but nothing else."
in caplog.text
)
assert (
"Please create an issue with your device model in additional info"
in caplog.text
)