Skip to content
37 changes: 36 additions & 1 deletion homeassistant/components/monoprice/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
"""The monoprice component."""
"""The Monoprice 6-Zone Amplifier integration."""
import asyncio

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant

PLATFORMS = ["media_player"]


async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Monoprice 6-Zone Amplifier component."""
return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Monoprice 6-Zone Amplifier from a config entry."""
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)

return unload_ok
95 changes: 95 additions & 0 deletions homeassistant/components/monoprice/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""Config flow for Monoprice 6-Zone Amplifier integration."""
import logging

from pymonoprice import get_async_monoprice
from serial import SerialException
import voluptuous as vol

from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_PORT

from .const import (
CONF_SOURCE_1,
CONF_SOURCE_2,
CONF_SOURCE_3,
CONF_SOURCE_4,
CONF_SOURCE_5,
CONF_SOURCE_6,
CONF_SOURCES,
)
from .const import DOMAIN # pylint:disable=unused-import

_LOGGER = logging.getLogger(__name__)

DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PORT): str,
vol.Optional(CONF_SOURCE_1): str,
vol.Optional(CONF_SOURCE_2): str,
vol.Optional(CONF_SOURCE_3): str,
vol.Optional(CONF_SOURCE_4): str,
vol.Optional(CONF_SOURCE_5): str,
vol.Optional(CONF_SOURCE_6): str,
}
)


async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.

Data has the keys from DATA_SCHEMA with values provided by the user.
"""
try:
await get_async_monoprice(data[CONF_PORT], hass.loop)
except SerialException:
_LOGGER.error("Error connecting to Monoprice controller")
raise CannotConnect

sources_config = {
1: data.get(CONF_SOURCE_1),
2: data.get(CONF_SOURCE_2),
3: data.get(CONF_SOURCE_3),
4: data.get(CONF_SOURCE_4),
5: data.get(CONF_SOURCE_5),
6: data.get(CONF_SOURCE_6),
}
sources = {
index: name.strip()
for index, name in sources_config.items()
if (name is not None and name.strip() != "")
}
# Return info that you want to store in the config entry.
return {CONF_PORT: data[CONF_PORT], CONF_SOURCES: sources}


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Monoprice 6-Zone Amplifier."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)

return self.async_create_entry(title=user_input[CONF_PORT], data=info)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)


class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidAuth(exceptions.HomeAssistantError):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't used.

"""Error to indicate there is invalid auth."""
10 changes: 10 additions & 0 deletions homeassistant/components/monoprice/const.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
"""Constants for the Monoprice 6-Zone Amplifier Media Player component."""

DOMAIN = "monoprice"

CONF_SOURCES = "sources"

CONF_SOURCE_1 = "source_1"
CONF_SOURCE_2 = "source_2"
CONF_SOURCE_3 = "source_3"
CONF_SOURCE_4 = "source_4"
CONF_SOURCE_5 = "source_5"
CONF_SOURCE_6 = "source_6"

SERVICE_SNAPSHOT = "snapshot"
SERVICE_RESTORE = "restore"
3 changes: 2 additions & 1 deletion homeassistant/components/monoprice/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/monoprice",
"requirements": ["pymonoprice==0.3"],
"dependencies": [],
"codeowners": ["@etsinko"]
"codeowners": ["@etsinko"],
"config_flow": true
}
150 changes: 74 additions & 76 deletions homeassistant/components/monoprice/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@

from pymonoprice import get_monoprice
from serial import SerialException
import voluptuous as vol

from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice
from homeassistant.components.media_player import MediaPlayerDevice
from homeassistant.components.media_player.const import (
SUPPORT_SELECT_SOURCE,
SUPPORT_TURN_OFF,
Expand All @@ -14,16 +13,10 @@
SUPPORT_VOLUME_SET,
SUPPORT_VOLUME_STEP,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_NAME,
CONF_PORT,
STATE_OFF,
STATE_ON,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.const import CONF_PORT, STATE_OFF, STATE_ON
from homeassistant.helpers import config_validation as cv, entity_platform, service

from .const import DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT
from .const import CONF_SOURCES, DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT

_LOGGER = logging.getLogger(__name__)

Expand All @@ -36,104 +29,89 @@
| SUPPORT_SELECT_SOURCE
)

ZONE_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})

SOURCE_SCHEMA = vol.Schema({vol.Required(CONF_NAME): cv.string})

CONF_ZONES = "zones"
CONF_SOURCES = "sources"
def _get_sources(sources_config):
source_id_name = {int(index): name for index, name in sources_config.items()}

DATA_MONOPRICE = "monoprice"
source_name_id = {v: k for k, v in source_id_name.items()}

# Valid zone ids: 11-16 or 21-26 or 31-36
ZONE_IDS = vol.All(
vol.Coerce(int),
vol.Any(
vol.Range(min=11, max=16), vol.Range(min=21, max=26), vol.Range(min=31, max=36)
),
)
source_names = sorted(source_name_id.keys(), key=lambda v: source_name_id[v])

# Valid source ids: 1-6
SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=6))
return [source_id_name, source_name_id, source_names]

MEDIA_PLAYER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.comp_entity_ids})

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_PORT): cv.string,
vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}),
vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}),
}
)


def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_devices):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename async_add_devices to async_add_entities.

"""Set up the Monoprice 6-zone amplifier platform."""
port = config.get(CONF_PORT)
port = config_entry.data.get(CONF_PORT)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Port is required so we can use dict[key].


try:
monoprice = get_monoprice(port)
monoprice = await hass.async_add_executor_job(get_monoprice, port)

@balloob balloob Mar 22, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do this in __init__.py we can raise ConfigEntryNotReady if you get a SerialException so it will automatically retry later.

@MartinHjelmare MartinHjelmare Mar 22, 2020

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe use get_async_monoprice instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MartinHjelmare that's my goal, and the code changes are relatively straightforward. The reason I'm not doing this yet is that pymonoprice is dependent on pyserial-asyncio for its async implementation, which does not seem to be maintained and is using old-style coroutine syntax. I'm afraid it's not going to work with future python versions.

except SerialException:
_LOGGER.error("Error connecting to Monoprice controller")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You want to add the port in case you have multiple set up.

return

sources = {
source_id: extra[CONF_NAME] for source_id, extra in config[CONF_SOURCES].items()
}
sources = _get_sources(config_entry.data.get(CONF_SOURCES))

devices = []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please rename devices to entities.

for i in range(1, 4):
for j in range(1, 7):
zone_id = (i * 10) + j
_LOGGER.info("Adding zone %d for port %s", zone_id, port)
devices.append(
MonopriceZone(monoprice, sources, config_entry.entry_id, zone_id)
)

hass.data[DATA_MONOPRICE] = []
for zone_id, extra in config[CONF_ZONES].items():
_LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME])
hass.data[DATA_MONOPRICE].append(
MonopriceZone(monoprice, sources, zone_id, extra[CONF_NAME])
)
async_add_devices(devices, True)

add_entities(hass.data[DATA_MONOPRICE], True)
platform = entity_platform.current_platform.get()

def service_handle(service):
def _call_service(entities, service_call):
for entity in entities:
if service_call.service == SERVICE_SNAPSHOT:
entity.snapshot()
elif service_call.service == SERVICE_RESTORE:
entity.restore()

@service.verify_domain_control(hass, DOMAIN)
async def async_service_handle(service_call):
"""Handle for services."""
entity_ids = service.data.get(ATTR_ENTITY_ID)

if entity_ids:
devices = [
device
for device in hass.data[DATA_MONOPRICE]
if device.entity_id in entity_ids
]
else:
devices = hass.data[DATA_MONOPRICE]
entities = await platform.async_extract_from_service(service_call)

for device in devices:
if service.service == SERVICE_SNAPSHOT:
device.snapshot()
elif service.service == SERVICE_RESTORE:
device.restore()
if not entities:
return

hass.async_add_executor_job(_call_service, entities, service_call)

hass.services.register(
DOMAIN, SERVICE_SNAPSHOT, service_handle, schema=MEDIA_PLAYER_SCHEMA
hass.services.async_register(
DOMAIN,
SERVICE_SNAPSHOT,
async_service_handle,
schema=cv.make_entity_service_schema({}),
)

hass.services.register(
DOMAIN, SERVICE_RESTORE, service_handle, schema=MEDIA_PLAYER_SCHEMA
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE,
async_service_handle,
schema=cv.make_entity_service_schema({}),
)


class MonopriceZone(MediaPlayerDevice):
"""Representation of a Monoprice amplifier zone."""

def __init__(self, monoprice, sources, zone_id, zone_name):
def __init__(self, monoprice, sources, namespace, zone_id):
"""Initialize new zone."""
self._monoprice = monoprice
# dict source_id -> source name
self._source_id_name = sources
self._source_id_name = sources[0]
# dict source name -> source_id
self._source_name_id = {v: k for k, v in sources.items()}
self._source_name_id = sources[1]
# ordered list of all source names
self._source_names = sorted(
self._source_name_id.keys(), key=lambda v: self._source_name_id[v]
)
self._source_names = sources[2]
self._zone_id = zone_id
self._name = zone_name
self._unique_id = f"{namespace}_{self._zone_id}"
self._name = f"Zone {self._zone_id}"

self._snapshot = None
self._state = None
Expand All @@ -156,6 +134,26 @@ def update(self):
self._source = None
return True

@property
def entity_registry_enabled_default(self):
"""Return if the entity should be enabled when first added to the entity registry."""
return self._zone_id < 20

@property
def device_info(self):
"""Return device info for this device."""
return {
"identifiers": {(DOMAIN, self.unique_id)},
"name": self.name,
"manufacturer": "Monoprice",
"model": "6-Zone Amplifier",
}

@property
def unique_id(self):
"""Return unique ID for this device."""
return self._unique_id

@property
def name(self):
"""Return the name of the zone."""
Expand Down
26 changes: 26 additions & 0 deletions homeassistant/components/monoprice/strings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"config": {
"title": "Monoprice 6-Zone Amplifier",
"step": {
"user": {
"title": "Connect to the device",
"data": {
"port": "Serial port",
"source_1": "Name of source #1",
"source_2": "Name of source #2",
"source_3": "Name of source #3",
"source_4": "Name of source #4",
"source_5": "Name of source #5",
"source_6": "Name of source #6"
}
}
},
"error": {
"cannot_connect": "Failed to connect, please try again",
"unknown": "Unexpected error"
},
"abort": {
"already_configured": "Device is already configured"
}
}
}
Loading