Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
77683bc
feat: Add madvr envy
iloveicedgreentea Jun 15, 2024
5b3141b
fix: await and pass entry directly
iloveicedgreentea Jun 15, 2024
86b73a2
fix: add attributes and unique id for sensors
iloveicedgreentea Jun 15, 2024
5d9591c
fix: reflect power state well, improve state detection
iloveicedgreentea Jun 15, 2024
0ce0ebf
fix: don't connect on init, add options, add reload on change, keep o…
iloveicedgreentea Jun 16, 2024
f126e2e
fix: cancel tasks on unload
iloveicedgreentea Jun 16, 2024
b427f91
fix: test connection via library
iloveicedgreentea Jun 16, 2024
91eea26
fix: wait for boot time
iloveicedgreentea Jun 16, 2024
e39c2e7
docs: add readme and license
iloveicedgreentea Jun 16, 2024
f12776a
fix: broken pipe in lib
iloveicedgreentea Jun 16, 2024
a1d7eb3
fix: detect out of band power off
iloveicedgreentea Jun 16, 2024
8e6b9de
fix: improve extra attributes
iloveicedgreentea Jun 16, 2024
f1ecd96
fix: fix unloading, add config flow test, limit to one platform
iloveicedgreentea Jun 25, 2024
df7320f
fix: use conf, refresh coordinator, other comments
iloveicedgreentea Jun 25, 2024
8f72143
fix: remove event data
iloveicedgreentea Jun 25, 2024
291858d
fix: fix tests passing, remove wake on lan
iloveicedgreentea Jun 25, 2024
b0fa76d
fix: dont allow to proceed unless connection works
iloveicedgreentea Jun 25, 2024
70f115e
chore: update dep
iloveicedgreentea Jun 25, 2024
37d9189
fix: update config flow, add constants
iloveicedgreentea Jun 26, 2024
657940b
fix: write state, use runtime data instead
iloveicedgreentea Jun 26, 2024
8f14119
fix: remove await
iloveicedgreentea Jun 26, 2024
c5c58c6
fix: move unloading and stuff to coordinator/init
iloveicedgreentea Jun 27, 2024
5044994
fix: pass in config entry with correct type
iloveicedgreentea Jun 27, 2024
5d108ae
fix: move queue and tasks to library
iloveicedgreentea Jun 27, 2024
919a356
fix: config flow error flow, tests, name, and update lib
iloveicedgreentea Jun 27, 2024
e2266c7
fix: update lib, leave connection open on setup
iloveicedgreentea Jun 27, 2024
214bdbb
fix: update lib
iloveicedgreentea Jun 27, 2024
3e3dc7f
fix: address comments, remove wol from lib
iloveicedgreentea Jun 29, 2024
b566c05
fix: remove unneeded options
iloveicedgreentea Jun 29, 2024
ec397db
fix: remove fields
iloveicedgreentea Jun 29, 2024
f6ab532
fix: simplify code, address comments
iloveicedgreentea Jun 29, 2024
12e91ac
fix: move error to lib
iloveicedgreentea Jun 29, 2024
381fbb2
fix: fix test
iloveicedgreentea Jun 29, 2024
25caf1e
fix: stronger types
iloveicedgreentea Jun 29, 2024
1041668
fix: update lib
iloveicedgreentea Jun 29, 2024
639f063
fix: missing text from options flow
iloveicedgreentea Jun 29, 2024
7f486c0
chore: remove options flow
iloveicedgreentea Jun 29, 2024
b8d3be1
chore: remove import
iloveicedgreentea Jun 30, 2024
8a6c035
chore: update comments
iloveicedgreentea Jun 30, 2024
0dfdad0
fix: get mac from device, persist
iloveicedgreentea Jun 30, 2024
c49a180
fix: add mac stuff to test
iloveicedgreentea Jun 30, 2024
1ff2c5a
fix: startup import errors
iloveicedgreentea Jun 30, 2024
4bb0efc
chore: stale comment
iloveicedgreentea Jun 30, 2024
35e33b7
fix: get mac from persisted config
iloveicedgreentea Jun 30, 2024
4f57fc0
chore: update lib
iloveicedgreentea Jun 30, 2024
f11fd6b
fix: persist mac in a better way
iloveicedgreentea Jul 4, 2024
b4cc6db
feat: use mac as unique ID for entry
iloveicedgreentea Jul 5, 2024
3720ad1
fix: use unique ID from mac, add proper device
iloveicedgreentea Jul 5, 2024
b372063
fix: will not be set in init potentially
iloveicedgreentea Jul 5, 2024
a96151a
fix: access mac
iloveicedgreentea Jul 5, 2024
e4e0e6b
fix: optimize, move error to lib
iloveicedgreentea Jul 6, 2024
7db53e5
feat: add coordinator test, use conf
iloveicedgreentea Jul 6, 2024
88fa8be
fix: use one mock, add init test
iloveicedgreentea Jul 6, 2024
af64d39
fix: not async
iloveicedgreentea Jul 6, 2024
73ec34c
feat: add remote test
iloveicedgreentea Jul 6, 2024
4a70401
Merge branch 'dev' into add-madvr-envy
iloveicedgreentea Jul 6, 2024
750842c
fix: types
iloveicedgreentea Jul 6, 2024
befb6bc
fix: patch client, expand remote tests
iloveicedgreentea Jul 6, 2024
d9a2597
fix: use snapshot test
iloveicedgreentea Jul 6, 2024
577b150
Merge branch 'dev' into add-madvr-envy
iloveicedgreentea Jul 6, 2024
b85237d
fix: update branding
iloveicedgreentea Jul 7, 2024
2ef3b2b
fix: add description, fix type check
iloveicedgreentea Jul 7, 2024
a25bec0
fix: update tests
iloveicedgreentea Jul 7, 2024
e2e5a59
fix: test
iloveicedgreentea Jul 7, 2024
f44c83b
fix: update test
iloveicedgreentea Jul 7, 2024
613e1f5
fix: camelcase
iloveicedgreentea Jul 7, 2024
927afeb
Fix
joostlek Jul 7, 2024
b976524
Merge branch 'dev' into add-madvr-envy
iloveicedgreentea Jul 7, 2024
e20c8d5
Merge branch 'refs/heads/add-madvr-envy2' into add-madvr-envy
joostlek Jul 7, 2024
fba9ac9
feat: strict typing
iloveicedgreentea Jul 7, 2024
58cdf68
fix: strict typing in lib
iloveicedgreentea Jul 7, 2024
a11aebe
fix: type will never be None
iloveicedgreentea Jul 7, 2024
239f964
fix: reference to mac, all tests passing
iloveicedgreentea Jul 7, 2024
d64f2a6
Merge branch 'dev' into add-madvr-envy
iloveicedgreentea Jul 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ homeassistant.components.logger.*
homeassistant.components.london_underground.*
homeassistant.components.lookin.*
homeassistant.components.luftdaten.*
homeassistant.components.madvr.*
homeassistant.components.mailbox.*
homeassistant.components.map.*
homeassistant.components.mastodon.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,8 @@ build.json @home-assistant/supervisor
/tests/components/lutron_caseta/ @swails @bdraco @danaues @eclair4151
/homeassistant/components/lyric/ @timmo001
/tests/components/lyric/ @timmo001
/homeassistant/components/madvr/ @iloveicedgreentea
/tests/components/madvr/ @iloveicedgreentea
/homeassistant/components/mastodon/ @fabaff
/homeassistant/components/matrix/ @PaarthShah
/tests/components/matrix/ @PaarthShah
Expand Down
70 changes: 70 additions & 0 deletions homeassistant/components/madvr/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""The madvr-envy integration."""

from __future__ import annotations

import logging

from madvr.madvr import Madvr

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant, callback

from .coordinator import MadVRCoordinator

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


type MadVRConfigEntry = ConfigEntry[MadVRCoordinator]

_LOGGER = logging.getLogger(__name__)


async def async_handle_unload(coordinator: MadVRCoordinator) -> None:
"""Handle unload."""
_LOGGER.debug("Integration unloading")
coordinator.client.stop()
await coordinator.client.async_cancel_tasks()
_LOGGER.debug("Integration closing connection")
await coordinator.client.close_connection()
_LOGGER.debug("Unloaded")


async def async_setup_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> bool:
"""Set up the integration from a config entry."""
assert entry.unique_id
madVRClient = Madvr(
host=entry.data[CONF_HOST],
logger=_LOGGER,
port=entry.data[CONF_PORT],
mac=entry.unique_id,
connect_timeout=10,
loop=hass.loop,
)
coordinator = MadVRCoordinator(hass, madVRClient)

entry.runtime_data = coordinator

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

@callback
async def handle_unload(event: Event) -> None:
"""Handle unload."""
await async_handle_unload(coordinator=coordinator)

# listen for core stop event
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_unload)

# handle loading operations
await coordinator.handle_coordinator_load()
return True


async def async_unload_entry(hass: HomeAssistant, entry: MadVRConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator: MadVRCoordinator = entry.runtime_data
await async_handle_unload(coordinator=coordinator)

return unload_ok
116 changes: 116 additions & 0 deletions homeassistant/components/madvr/config_flow.py
Comment thread
iloveicedgreentea marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Config flow for the integration."""

import asyncio
import logging
from typing import Any

import aiohttp
from madvr.madvr import HeartBeatError, Madvr
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT

from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
from .errors import CannotConnect

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(
CONF_HOST,
): str,
vol.Required(
CONF_PORT,
default=DEFAULT_PORT,
): int,
}
)

RETRY_INTERVAL = 1


class MadVRConfigFlow(ConfigFlow, domain=DOMAIN):
Comment thread
iloveicedgreentea marked this conversation as resolved.
"""Handle a config flow for the integration."""

VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}

if user_input is not None:
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]

try:
# ensure we can connect and get the mac address from device
mac = await self._test_connection(host, port)
Comment thread
iloveicedgreentea marked this conversation as resolved.
except CannotConnect:
_LOGGER.error("CannotConnect error caught")
errors["base"] = "cannot_connect"
else:
if not mac:
errors["base"] = "no_mac"
if not errors:
_LOGGER.debug("MAC address found: %s", mac)
# this will prevent the user from adding the same device twice and persist the mac address
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()

# create the entry
return self.async_create_entry(
title=DEFAULT_NAME,
data=user_input,
)
Comment thread
iloveicedgreentea marked this conversation as resolved.

# this will show the form or allow the user to retry if there was an error
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)

async def _test_connection(self, host: str, port: int) -> str:
"""Test if we can connect to the device and grab the mac."""
madvr_client = Madvr(host=host, port=port, loop=self.hass.loop)
_LOGGER.debug("Testing connection to madVR at %s:%s", host, port)
# try to connect
try:
await asyncio.wait_for(madvr_client.open_connection(), timeout=15)
# connection can raise HeartBeatError if the device is not available or connection does not work
except (TimeoutError, aiohttp.ClientError, OSError, HeartBeatError) as err:
_LOGGER.error("Error connecting to madVR: %s", err)
raise CannotConnect from err
Comment thread
iloveicedgreentea marked this conversation as resolved.

# check if we are connected
if not madvr_client.connected:
raise CannotConnect("Connection failed")

# background tasks needed to capture realtime info
await madvr_client.async_add_tasks()

# wait for client to capture device info
retry_time = 15
while not madvr_client.mac_address and retry_time > 0:
await asyncio.sleep(RETRY_INTERVAL)
retry_time -= 1

mac_address = madvr_client.mac_address
if mac_address:
_LOGGER.debug("Connected to madVR with MAC: %s", mac_address)
# close this connection because this client object will not be reused
await self._close_test_connection(madvr_client)
_LOGGER.debug("Connection test successful")
return mac_address

async def _close_test_connection(self, madvr_client: Madvr) -> None:
"""Close the test connection."""
madvr_client.stop()
await madvr_client.async_cancel_tasks()
await madvr_client.close_connection()
6 changes: 6 additions & 0 deletions homeassistant/components/madvr/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants for the madvr-envy integration."""

DOMAIN = "madvr"

DEFAULT_NAME = "envy"
DEFAULT_PORT = 44077
50 changes: 50 additions & 0 deletions homeassistant/components/madvr/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Coordinator for handling data fetching and updates."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

from madvr.madvr import Madvr

from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

if TYPE_CHECKING:
from . import MadVRConfigEntry


class MadVRCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Madvr coordinator for Envy (push-based API)."""

config_entry: MadVRConfigEntry

def __init__(
self,
hass: HomeAssistant,
client: Madvr,
) -> None:
"""Initialize madvr coordinator."""
super().__init__(hass, _LOGGER, name=DOMAIN)
assert self.config_entry.unique_id
self.mac = self.config_entry.unique_id
self.client = client
self.client.set_update_callback(self.handle_push_data)
_LOGGER.debug("MadVRCoordinator initialized with mac: %s", self.mac)

def handle_push_data(self, data: dict[str, Any]) -> None:
"""Handle new data pushed from the API."""
_LOGGER.debug("Received push data: %s", data)
# inform HA that we have new data
self.async_set_updated_data(data)

async def handle_coordinator_load(self) -> None:
"""Handle operations on integration load."""
_LOGGER.debug("Using loop: %s", self.client.loop)
# tell the library to start background tasks
await self.client.async_add_tasks()
_LOGGER.debug("Added %s tasks to client", len(self.client.tasks))
5 changes: 5 additions & 0 deletions homeassistant/components/madvr/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Errors for the madvr component."""


class CannotConnect(Exception):
"""Error to indicate we cannot connect."""
10 changes: 10 additions & 0 deletions homeassistant/components/madvr/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "madvr",
"name": "madVR Envy",
"codeowners": ["@iloveicedgreentea"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/madvr",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["py-madvr2==1.6.27"]
}
86 changes: 86 additions & 0 deletions homeassistant/components/madvr/remote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Support for madVR remote control."""

from __future__ import annotations

from collections.abc import Iterable
import logging
from typing import Any

from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from . import MadVRConfigEntry
from .const import DOMAIN
from .coordinator import MadVRCoordinator

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(
hass: HomeAssistant,
entry: MadVRConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the madVR remote."""
coordinator = entry.runtime_data
async_add_entities(
[
MadvrRemote(coordinator),
]
)


class MadvrRemote(CoordinatorEntity[MadVRCoordinator], RemoteEntity):
"""Remote entity for the madVR integration."""

_attr_has_entity_name = True
_attr_name = None

def __init__(
self,
coordinator: MadVRCoordinator,
) -> None:
"""Initialize the remote entity."""
super().__init__(coordinator)
self.madvr_client = coordinator.client
self._attr_unique_id = coordinator.mac
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.mac)},
name="madVR Envy",
manufacturer="madVR",
model="Envy",
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)},
)

@property
def is_on(self) -> bool:
"""Return true if the device is on."""
return self.madvr_client.is_on

async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the device."""
_LOGGER.debug("Turning off")
try:
await self.madvr_client.power_off()
except (ConnectionError, NotImplementedError) as err:
_LOGGER.error("Failed to turn off device %s", err)

async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the device."""
_LOGGER.debug("Turning on device")

try:
await self.madvr_client.power_on(mac=self.coordinator.mac)
except (ConnectionError, NotImplementedError) as err:
_LOGGER.error("Failed to turn on device %s", err)

async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None:
"""Send a command to one device."""
_LOGGER.debug("adding command %s", command)
try:
await self.madvr_client.add_command_to_queue(command)
except (ConnectionError, NotImplementedError) as err:
_LOGGER.error("Failed to send command %s", err)
25 changes: 25 additions & 0 deletions homeassistant/components/madvr/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"config": {
"step": {
"user": {
"title": "Setup madVR Envy",
"description": "Your device needs to be turned in order to add the integation. ",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of your madVR Envy device.",
"port": "The port your madVR Envy is listening on. In 99% of cases, leave this as the default."
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_mac": "A MAC address was not found. It required to identify the device. Please ensure your device is connectable."
}
}
}
Loading