Add Brother Printer integration (#30359)

* Init entities as unavailable when offline

* Initial commit

* Fix CODEOWNERS

* CODEOWNERS

* Run script.hassfest

* Add initial test

* Bump library

* More tests

* Tests

* Add new sensors and fix KeyError

* Fix unique_id and device_info

* Fix check for configured device

* More tests

* Bump library version

* Add uptime sensor

* Use config entry unique ID

* Run python3 -m script.gen_requirements_all

* Fix pylint error

* Remove pysnmp dependency

* Raise ConfigEntryNotReady when device offline at HA start

* Remove period from logging message

* Generator simplification

* Change raise_on_progress

* Rename data to printer

* Move update state to async_update

* Remove unused _unit_of_measurement

* Remove update of device_info

* Suggested change for tests

* Remove unnecessary argument

* Suggested change
pull/30524/head
Maciej Bieniek 2020-01-06 18:06:16 +01:00 committed by Andrew Sayre
parent 8257ea30c0
commit 21029b1d7b
14 changed files with 595 additions and 0 deletions

View File

@ -95,6 +95,9 @@ omit =
homeassistant/components/broadlink/remote.py
homeassistant/components/broadlink/sensor.py
homeassistant/components/broadlink/switch.py
homeassistant/components/brother/__init__.py
homeassistant/components/brother/sensor.py
homeassistant/components/brother/const.py
homeassistant/components/brottsplatskartan/sensor.py
homeassistant/components/browser/*
homeassistant/components/brunt/cover.py

View File

@ -51,6 +51,7 @@ homeassistant/components/blink/* @fronzbot
homeassistant/components/bmw_connected_drive/* @gerard33
homeassistant/components/braviatv/* @robbiet480
homeassistant/components/broadlink/* @danielhiversen @felipediel
homeassistant/components/brother/* @bieniu
homeassistant/components/brunt/* @eavanvalkenburg
homeassistant/components/bt_smarthub/* @jxwolstenholme
homeassistant/components/buienradar/* @mjj4791 @ties

View File

@ -0,0 +1,102 @@
"""The Brother component."""
import asyncio
from datetime import timedelta
import logging
from brother import Brother, SnmpError, UnsupportedModel
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.core import Config, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.util import Throttle
from .const import DOMAIN
PLATFORMS = ["sensor"]
DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: Config):
"""Set up the Brother component."""
hass.data[DOMAIN] = {}
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Brother from a config entry."""
host = entry.data[CONF_HOST]
kind = entry.data[CONF_TYPE]
brother = BrotherPrinterData(host, kind)
await brother.async_update()
if not brother.available:
raise ConfigEntryNotReady()
hass.data[DOMAIN][entry.entry_id] = brother
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
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class BrotherPrinterData:
"""Define an object to hold sensor data."""
def __init__(self, host, kind):
"""Initialize."""
self._brother = Brother(host, kind=kind)
self.host = host
self.model = None
self.serial = None
self.firmware = None
self.available = False
self.data = {}
self.unavailable_logged = False
@Throttle(DEFAULT_SCAN_INTERVAL)
async def async_update(self):
"""Update data via library."""
try:
await self._brother.async_update()
except (ConnectionError, SnmpError, UnsupportedModel) as error:
if not self.unavailable_logged:
_LOGGER.error(
"Could not fetch data from %s, error: %s", self.host, error
)
self.unavailable_logged = True
self.available = self._brother.available
return
self.model = self._brother.model
self.serial = self._brother.serial
self.firmware = self._brother.firmware
self.available = self._brother.available
self.data = self._brother.data
if self.available and self.unavailable_logged:
_LOGGER.info("Printer %s is available again", self.host)
self.unavailable_logged = False

View File

@ -0,0 +1,69 @@
"""Adds config flow for Brother Printer."""
import ipaddress
import re
from brother import Brother, SnmpError, UnsupportedModel
import voluptuous as vol
from homeassistant import config_entries, exceptions
from homeassistant.const import CONF_HOST, CONF_TYPE
from .const import DOMAIN, PRINTER_TYPES # pylint:disable=unused-import
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST, default=""): str,
vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES),
}
)
def host_valid(host):
"""Return True if hostname or IP address is valid."""
try:
if ipaddress.ip_address(host).version == (4 or 6):
return True
except ValueError:
disallowed = re.compile(r"[^a-zA-Z\d\-]")
return all(x and not disallowed.search(x) for x in host.split("."))
class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Brother Printer."""
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:
if not host_valid(user_input[CONF_HOST]):
raise InvalidHost()
brother = Brother(user_input[CONF_HOST])
await brother.async_update()
await self.async_set_unique_id(brother.serial.lower())
self._abort_if_unique_id_configured()
title = f"{brother.model} {brother.serial}"
return self.async_create_entry(title=title, data=user_input)
except InvalidHost:
errors[CONF_HOST] = "wrong_host"
except ConnectionError:
errors["base"] = "connection_error"
except SnmpError:
errors["base"] = "snmp_error"
except UnsupportedModel:
return self.async_abort(reason="unsupported_model")
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
class InvalidHost(exceptions.HomeAssistantError):
"""Error to indicate that hostname/IP address is invalid."""

View File

@ -0,0 +1,132 @@
"""Constants for Brother integration."""
ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life"
ATTR_BLACK_INK_REMAINING = "black_ink_remaining"
ATTR_BLACK_TONER_REMAINING = "black_toner_remaining"
ATTR_BW_COUNTER = "b/w_counter"
ATTR_COLOR_COUNTER = "color_counter"
ATTR_CYAN_INK_REMAINING = "cyan_ink_remaining"
ATTR_CYAN_TONER_REMAINING = "cyan_toner_remaining"
ATTR_DRUM_COUNTER = "drum_counter"
ATTR_DRUM_REMAINING_LIFE = "drum_remaining_life"
ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages"
ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life"
ATTR_ICON = "icon"
ATTR_LABEL = "label"
ATTR_LASER_REMAINING_LIFE = "laser_remaining_life"
ATTR_MAGENTA_INK_REMAINING = "magenta_ink_remaining"
ATTR_MAGENTA_TONER_REMAINING = "magenta_toner_remaining"
ATTR_MANUFACTURER = "Brother"
ATTR_PAGE_COUNTER = "page_counter"
ATTR_PF_KIT_1_REMAINING_LIFE = "pf_kit_1_remaining_life"
ATTR_PF_KIT_MP_REMAINING_LIFE = "pf_kit_mp_remaining_life"
ATTR_STATUS = "status"
ATTR_UNIT = "unit"
ATTR_UPTIME = "uptime"
ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining"
ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining"
DOMAIN = "brother"
UNIT_PAGES = "p"
UNIT_DAYS = "days"
UNIT_PERCENT = "%"
PRINTER_TYPES = ["laser", "ink"]
SENSOR_TYPES = {
ATTR_STATUS: {
ATTR_ICON: "icon:mdi:printer",
ATTR_LABEL: ATTR_STATUS.title(),
ATTR_UNIT: None,
},
ATTR_PAGE_COUNTER: {
ATTR_ICON: "mdi:file-document-outline",
ATTR_LABEL: ATTR_PAGE_COUNTER.replace("_", " ").title(),
ATTR_UNIT: UNIT_PAGES,
},
ATTR_BW_COUNTER: {
ATTR_ICON: "mdi:file-document-outline",
ATTR_LABEL: ATTR_BW_COUNTER.replace("_", " ").title(),
ATTR_UNIT: UNIT_PAGES,
},
ATTR_COLOR_COUNTER: {
ATTR_ICON: "mdi:file-document-outline",
ATTR_LABEL: ATTR_COLOR_COUNTER.replace("_", " ").title(),
ATTR_UNIT: UNIT_PAGES,
},
ATTR_DRUM_REMAINING_LIFE: {
ATTR_ICON: "mdi:chart-donut",
ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_BELT_UNIT_REMAINING_LIFE: {
ATTR_ICON: "mdi:current-ac",
ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_FUSER_REMAINING_LIFE: {
ATTR_ICON: "mdi:water-outline",
ATTR_LABEL: ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_LASER_REMAINING_LIFE: {
ATTR_ICON: "mdi:spotlight-beam",
ATTR_LABEL: ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_PF_KIT_1_REMAINING_LIFE: {
ATTR_ICON: "mdi:printer-3d",
ATTR_LABEL: ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_PF_KIT_MP_REMAINING_LIFE: {
ATTR_ICON: "mdi:printer-3d",
ATTR_LABEL: ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_BLACK_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_CYAN_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_MAGENTA_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_YELLOW_TONER_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_BLACK_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_BLACK_INK_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_CYAN_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_CYAN_INK_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_MAGENTA_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_YELLOW_INK_REMAINING: {
ATTR_ICON: "mdi:printer-3d-nozzle",
ATTR_LABEL: ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(),
ATTR_UNIT: UNIT_PERCENT,
},
ATTR_UPTIME: {
ATTR_ICON: "mdi:timer",
ATTR_LABEL: ATTR_UPTIME.title(),
ATTR_UNIT: UNIT_DAYS,
},
}

View File

@ -0,0 +1,9 @@
{
"domain": "brother",
"name": "Brother Printer",
"documentation": "https://www.home-assistant.io/integrations/brother",
"dependencies": [],
"codeowners": ["@bieniu"],
"requirements": ["brother==0.1.4"],
"config_flow": true
}

View File

@ -0,0 +1,104 @@
"""Support for the Brother service."""
import logging
from homeassistant.helpers.entity import Entity
from .const import (
ATTR_DRUM_COUNTER,
ATTR_DRUM_REMAINING_LIFE,
ATTR_DRUM_REMAINING_PAGES,
ATTR_ICON,
ATTR_LABEL,
ATTR_MANUFACTURER,
ATTR_UNIT,
DOMAIN,
SENSOR_TYPES,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Add Brother entities from a config_entry."""
brother = hass.data[DOMAIN][config_entry.entry_id]
sensors = []
name = brother.model
device_info = {
"identifiers": {(DOMAIN, brother.serial)},
"name": brother.model,
"manufacturer": ATTR_MANUFACTURER,
"model": brother.model,
"sw_version": brother.firmware,
}
for sensor in SENSOR_TYPES:
if sensor in brother.data:
sensors.append(BrotherPrinterSensor(brother, name, sensor, device_info))
async_add_entities(sensors, True)
class BrotherPrinterSensor(Entity):
"""Define an Brother Printer sensor."""
def __init__(self, printer, name, kind, device_info):
"""Initialize."""
self.printer = printer
self._name = name
self._device_info = device_info
self._unique_id = f"{self.printer.serial.lower()}_{kind}"
self.kind = kind
self._state = None
self._attrs = {}
@property
def name(self):
"""Return the name."""
return f"{self._name} {SENSOR_TYPES[self.kind][ATTR_LABEL]}"
@property
def state(self):
"""Return the state."""
return self._state
@property
def device_state_attributes(self):
"""Return the state attributes."""
if self.kind == ATTR_DRUM_REMAINING_LIFE:
self._attrs["remaining_pages"] = self.printer.data.get(
ATTR_DRUM_REMAINING_PAGES
)
self._attrs["counter"] = self.printer.data.get(ATTR_DRUM_COUNTER)
return self._attrs
@property
def icon(self):
"""Return the icon."""
return SENSOR_TYPES[self.kind][ATTR_ICON]
@property
def unique_id(self):
"""Return a unique_id for this entity."""
return self._unique_id
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
return SENSOR_TYPES[self.kind][ATTR_UNIT]
@property
def available(self):
"""Return True if entity is available."""
return self.printer.available
@property
def device_info(self):
"""Return the device info."""
return self._device_info
async def async_update(self):
"""Update the data from printer."""
await self.printer.async_update()
self._state = self.printer.data.get(self.kind)

View File

@ -0,0 +1,23 @@
{
"config": {
"title": "Brother Printer",
"step": {
"user": {
"title": "Brother Printer",
"description": "Set up Brother printer integration. If you have problems with configuration go to: https://www.home-assistant.io/integrations/brother",
"data": {
"host": "Printer hostname or IP address",
"type": "Type of the printer"
}
}
},
"error": {
"wrong_host": "Invalid hostname or IP address.",
"connection_error": "Connection error.",
"snmp_error": "SNMP server turned off or printer not supported."
},
"abort": {
"unsupported_model": "This printer model is not supported."
}
}
}

View File

@ -13,6 +13,7 @@ FLOWS = [
"ambiclimate",
"ambient_station",
"axis",
"brother",
"cast",
"cert_expiry",
"coolmaster",

View File

@ -339,6 +339,9 @@ braviarc-homeassistant==0.3.7.dev0
# homeassistant.components.broadlink
broadlink==0.12.0
# homeassistant.components.brother
brother==0.1.4
# homeassistant.components.brottsplatskartan
brottsplatskartan==0.0.1

View File

@ -120,6 +120,9 @@ bomradarloop==0.1.3
# homeassistant.components.broadlink
broadlink==0.12.0
# homeassistant.components.brother
brother==0.1.4
# homeassistant.components.buienradar
buienradar==1.0.1

View File

@ -0,0 +1 @@
"""Tests for Brother Printer."""

View File

@ -0,0 +1,127 @@
"""Define tests for the Brother Printer config flow."""
import json
from asynctest import patch
from brother import SnmpError, UnsupportedModel
from homeassistant import data_entry_flow
from homeassistant.components.brother import config_flow
from homeassistant.components.brother.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TYPE
from tests.common import load_fixture
CONFIG = {
CONF_HOST: "localhost",
CONF_NAME: "Printer",
CONF_TYPE: "laser",
}
async def test_show_form(hass):
"""Test that the form is served with no input."""
flow = config_flow.BrotherConfigFlow()
flow.hass = hass
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
async def test_create_entry_with_hostname(hass):
"""Test that the user step works with printer hostname."""
with patch(
"brother.Brother._get_data",
return_value=json.loads(load_fixture("brother_printer_data.json")),
):
flow = config_flow.BrotherConfigFlow()
flow.hass = hass
flow.context = {}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "HL-L2340DW 0123456789"
assert result["data"][CONF_HOST] == CONFIG[CONF_HOST]
assert result["data"][CONF_NAME] == CONFIG[CONF_NAME]
async def test_create_entry_with_ip_address(hass):
"""Test that the user step works with printer IP address."""
with patch(
"brother.Brother._get_data",
return_value=json.loads(load_fixture("brother_printer_data.json")),
):
flow = config_flow.BrotherConfigFlow()
flow.hass = hass
flow.context = {}
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
data={CONF_NAME: "Name", CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "HL-L2340DW 0123456789"
assert result["data"][CONF_HOST] == "127.0.0.1"
assert result["data"][CONF_NAME] == "Name"
async def test_invalid_hostname(hass):
"""Test invalid hostname in user_input."""
flow = config_flow.BrotherConfigFlow()
flow.hass = hass
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "user"},
data={CONF_NAME: "Name", CONF_HOST: "invalid/hostname", CONF_TYPE: "laser"},
)
assert result["errors"] == {CONF_HOST: "wrong_host"}
async def test_connection_error(hass):
"""Test connection to host error."""
with patch("brother.Brother._get_data", side_effect=ConnectionError()):
flow = config_flow.BrotherConfigFlow()
flow.hass = hass
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=CONFIG
)
assert result["errors"] == {"base": "connection_error"}
async def test_snmp_error(hass):
"""Test SNMP error."""
with patch("brother.Brother._get_data", side_effect=SnmpError("error")):
flow = config_flow.BrotherConfigFlow()
flow.hass = hass
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=CONFIG
)
assert result["errors"] == {"base": "snmp_error"}
async def test_unsupported_model_error(hass):
"""Test unsupported printer model error."""
with patch("brother.Brother._get_data", side_effect=UnsupportedModel("error")):
flow = config_flow.BrotherConfigFlow()
flow.hass = hass
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=CONFIG
)
assert result["type"] == "abort"
assert result["reason"] == "unsupported_model"

View File

@ -0,0 +1,17 @@
{
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.10.0": ["000104000003da"],
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.17.0": "1.17",
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.8.0": [
"63010400000001",
"110104000003da",
"410104000023f0",
"31010400000001",
"6f010400001d4c",
"81010400000050",
"8601040000000a"
],
"1.3.6.1.4.1.2435.2.4.3.2435.5.13.3.0": "Brother HL-L2340DW",
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.11.0": ["82010400002b06"],
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.1.0": "0123456789",
"1.3.6.1.4.1.2435.2.3.9.4.2.1.5.4.5.2.0": "WAITING "
}