Add FireServiceRota/BrandweerRooster integration (#38206)

Co-authored-by: Robert Svensson <Kane610@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
This commit is contained in:
Ron Klinkien 2020-11-25 16:38:49 +01:00 committed by GitHub
parent 1de2554f70
commit ea52ffc2d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 705 additions and 0 deletions

View File

@ -262,6 +262,9 @@ omit =
homeassistant/components/fibaro/*
homeassistant/components/filesize/sensor.py
homeassistant/components/fints/sensor.py
homeassistant/components/fireservicerota/__init__.py
homeassistant/components/fireservicerota/const.py
homeassistant/components/fireservicerota/sensor.py
homeassistant/components/firmata/__init__.py
homeassistant/components/firmata/binary_sensor.py
homeassistant/components/firmata/board.py

View File

@ -146,6 +146,7 @@ homeassistant/components/ezviz/* @baqs
homeassistant/components/fastdotcom/* @rohankapoorcom
homeassistant/components/file/* @fabaff
homeassistant/components/filter/* @dgomes
homeassistant/components/fireservicerota/* @cyberjunky
homeassistant/components/firmata/* @DaAwesomeP
homeassistant/components/fixer/* @fabaff
homeassistant/components/flick_electric/* @ZephireNZ

View File

@ -0,0 +1,246 @@
"""The FireServiceRota integration."""
import asyncio
from datetime import timedelta
import logging
from pyfireservicerota import (
ExpiredTokenError,
FireServiceRota,
FireServiceRotaIncidents,
InvalidAuthError,
InvalidTokenError,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, WSS_BWRURL
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
SUPPORTED_PLATFORMS = {SENSOR_DOMAIN}
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the FireServiceRota component."""
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up FireServiceRota from a config entry."""
hass.data.setdefault(DOMAIN, {})
coordinator = FireServiceRotaCoordinator(hass, entry)
await coordinator.setup()
await coordinator.async_availability_update()
if coordinator.token_refresh_failure:
return False
hass.data[DOMAIN][entry.entry_id] = coordinator
for platform in SUPPORTED_PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload FireServiceRota config entry."""
hass.data[DOMAIN][entry.entry_id].websocket.stop_listener()
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, platform)
for platform in SUPPORTED_PLATFORMS
]
)
)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
return unload_ok
class FireServiceRotaOauth:
"""Handle authentication tokens."""
def __init__(self, hass, entry, fsr):
"""Initialize the oauth object."""
self._hass = hass
self._entry = entry
self._url = entry.data[CONF_URL]
self._username = entry.data[CONF_USERNAME]
self._fsr = fsr
async def async_refresh_tokens(self) -> bool:
"""Refresh tokens and update config entry."""
_LOGGER.debug("Refreshing authentication tokens after expiration")
try:
token_info = await self._hass.async_add_executor_job(
self._fsr.refresh_tokens
)
except (InvalidAuthError, InvalidTokenError):
_LOGGER.error("Error refreshing tokens, triggered reauth workflow")
self._hass.add_job(
self._hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_REAUTH},
data={
**self._entry.data,
},
)
)
return False
_LOGGER.debug("Saving new tokens in config entry")
self._hass.config_entries.async_update_entry(
self._entry,
data={
"auth_implementation": DOMAIN,
CONF_URL: self._url,
CONF_USERNAME: self._username,
CONF_TOKEN: token_info,
},
)
return True
class FireServiceRotaWebSocket:
"""Define a FireServiceRota websocket manager object."""
def __init__(self, hass, entry):
"""Initialize the websocket object."""
self._hass = hass
self._entry = entry
self._fsr_incidents = FireServiceRotaIncidents(on_incident=self._on_incident)
self._incident_data = None
def _construct_url(self) -> str:
"""Return URL with latest access token."""
return WSS_BWRURL.format(
self._entry.data[CONF_URL], self._entry.data[CONF_TOKEN]["access_token"]
)
def incident_data(self) -> object:
"""Return incident data."""
return self._incident_data
def _on_incident(self, data) -> None:
"""Received new incident, update data."""
_LOGGER.debug("Received new incident via websocket: %s", data)
self._incident_data = data
dispatcher_send(self._hass, f"{DOMAIN}_{self._entry.entry_id}_update")
def start_listener(self) -> None:
"""Start the websocket listener."""
_LOGGER.debug("Starting incidents listener")
self._fsr_incidents.start(self._construct_url())
def stop_listener(self) -> None:
"""Stop the websocket listener."""
_LOGGER.debug("Stopping incidents listener")
self._fsr_incidents.stop()
class FireServiceRotaCoordinator(DataUpdateCoordinator):
"""Getting the latest data from fireservicerota."""
def __init__(self, hass, entry):
"""Initialize the data object."""
self._hass = hass
self._entry = entry
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_method=self.async_availability_update,
update_interval=MIN_TIME_BETWEEN_UPDATES,
)
self._url = entry.data[CONF_URL]
self._tokens = entry.data[CONF_TOKEN]
self.token_refresh_failure = False
self.incident_id = None
self.fsr = FireServiceRota(base_url=self._url, token_info=self._tokens)
self.oauth = FireServiceRotaOauth(
self._hass,
self._entry,
self.fsr,
)
self.websocket = FireServiceRotaWebSocket(self._hass, self._entry)
async def setup(self) -> None:
"""Set up the coordinator."""
await self._hass.async_add_executor_job(self.websocket.start_listener)
async def update_call(self, func, *args):
"""Perform update call and return data."""
if self.token_refresh_failure:
return
try:
return await self._hass.async_add_executor_job(func, *args)
except (ExpiredTokenError, InvalidTokenError):
self.websocket.stop_listener()
self.token_refresh_failure = True
self.update_interval = None
if await self.oauth.async_refresh_tokens():
self.update_interval = MIN_TIME_BETWEEN_UPDATES
self.token_refresh_failure = False
self.websocket.start_listener()
return await self._hass.async_add_executor_job(func, *args)
async def async_availability_update(self) -> None:
"""Get the latest availability data."""
_LOGGER.debug("Updating availability data")
return await self.update_call(
self.fsr.get_availability, str(self._hass.config.time_zone)
)
async def async_response_update(self) -> object:
"""Get the latest incident response data."""
data = self.websocket.incident_data()
if data is None or "id" not in data:
return
self.incident_id = data("id")
_LOGGER.debug("Updating incident response data for id: %s", self.incident_id)
return await self.update_call(self.fsr.get_incident_response, self.incident_id)
async def async_set_response(self, value) -> None:
"""Set incident response status."""
_LOGGER.debug(
"Setting incident response for incident '%s' to status '%s'",
self.incident_id,
value,
)
await self.update_call(self.fsr.set_incident_response, self.incident_id, value)

View File

@ -0,0 +1,129 @@
"""Config flow for FireServiceRota."""
from pyfireservicerota import FireServiceRota, InvalidAuthError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_URL, CONF_USERNAME
from .const import DOMAIN, URL_LIST # pylint: disable=unused-import
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL, default="www.brandweerrooster.nl"): vol.In(URL_LIST),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class FireServiceRotaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a FireServiceRota config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize config flow."""
self.api = None
self._base_url = None
self._username = None
self._password = None
self._existing_entry = None
self._description_placeholders = None
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is None:
return self._show_setup_form(user_input, errors)
return await self._validate_and_create_entry(user_input, "user")
async def _validate_and_create_entry(self, user_input, step_id):
"""Check if config is valid and create entry if so."""
self._password = user_input[CONF_PASSWORD]
extra_inputs = user_input
if self._existing_entry:
extra_inputs = self._existing_entry
self._username = extra_inputs[CONF_USERNAME]
self._base_url = extra_inputs[CONF_URL]
if self.unique_id is None:
await self.async_set_unique_id(self._username)
self._abort_if_unique_id_configured()
try:
self.api = FireServiceRota(
base_url=self._base_url,
username=self._username,
password=self._password,
)
token_info = await self.hass.async_add_executor_job(self.api.request_tokens)
except InvalidAuthError:
self.api = None
return self.async_show_form(
step_id=step_id,
data_schema=DATA_SCHEMA,
errors={"base": "invalid_auth"},
)
data = {
"auth_implementation": DOMAIN,
CONF_URL: self._base_url,
CONF_USERNAME: self._username,
CONF_TOKEN: token_info,
}
if step_id == "user":
return self.async_create_entry(title=self._username, data=data)
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.unique_id == self.unique_id:
self.hass.config_entries.async_update_entry(entry, data=data)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
def _show_setup_form(self, user_input=None, errors=None, step_id="user"):
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
if step_id == "user":
schema = {
vol.Required(CONF_URL, default="www.brandweerrooster.nl"): vol.In(
URL_LIST
),
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
else:
schema = {vol.Required(CONF_PASSWORD): str}
return self.async_show_form(
step_id=step_id,
data_schema=vol.Schema(schema),
errors=errors or {},
description_placeholders=self._description_placeholders,
)
async def async_step_reauth(self, user_input=None):
"""Get new tokens for a config entry that can't authenticate."""
if not self._existing_entry:
await self.async_set_unique_id(user_input[CONF_USERNAME])
self._existing_entry = user_input.copy()
self._description_placeholders = {"username": user_input[CONF_USERNAME]}
user_input = None
if user_input is None:
return self._show_setup_form(step_id=config_entries.SOURCE_REAUTH)
return await self._validate_and_create_entry(
user_input, config_entries.SOURCE_REAUTH
)

View File

@ -0,0 +1,9 @@
"""Constants for the FireServiceRota integration."""
DOMAIN = "fireservicerota"
URL_LIST = {
"www.brandweerrooster.nl": "BrandweerRooster",
"www.fireservicerota.co.uk": "FireServiceRota",
}
WSS_BWRURL = "wss://{0}/cable?access_token={1}"

View File

@ -0,0 +1,8 @@
{
"domain": "fireservicerota",
"name": "FireServiceRota",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fireservicerota",
"requirements": ["pyfireservicerota==0.0.40"],
"codeowners": ["@cyberjunky"]
}

View File

@ -0,0 +1,128 @@
"""Sensor platform for FireServiceRota integration."""
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import HomeAssistantType
from .const import DOMAIN as FIRESERVICEROTA_DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
) -> None:
"""Set up FireServiceRota sensor based on a config entry."""
coordinator = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id]
async_add_entities([IncidentsSensor(coordinator)])
class IncidentsSensor(RestoreEntity):
"""Representation of FireServiceRota incidents sensor."""
def __init__(self, coordinator):
"""Initialize."""
self._coordinator = coordinator
self._entry_id = self._coordinator._entry.entry_id
self._unique_id = f"{self._coordinator._entry.unique_id}_Incidents"
self._state = None
self._state_attributes = {}
@property
def name(self) -> str:
"""Return the name of the sensor."""
return "Incidents"
@property
def icon(self) -> str:
"""Return the icon to use in the frontend."""
if (
"prio" in self._state_attributes
and self._state_attributes["prio"][0] == "a"
):
return "mdi:ambulance"
return "mdi:fire-truck"
@property
def state(self) -> str:
"""Return the state of the sensor."""
return self._state
@property
def unique_id(self) -> str:
"""Return the unique ID of the sensor."""
return self._unique_id
@property
def should_poll(self) -> bool:
"""No polling needed."""
return False
@property
def device_state_attributes(self) -> object:
"""Return available attributes for sensor."""
attr = {}
data = self._state_attributes
if not data:
return attr
for value in (
"trigger",
"created_at",
"message_to_speech_url",
"prio",
"type",
"responder_mode",
"can_respond_until",
):
if data.get(value):
attr[value] = data[value]
if "address" not in data:
continue
for address_value in (
"latitude",
"longitude",
"address_type",
"formatted_address",
):
if address_value in data["address"]:
attr[address_value] = data["address"][address_value]
return attr
async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass."""
await super().async_added_to_hass()
state = await self.async_get_last_state()
if state:
self._state = state.state
self._state_attributes = state.attributes
_LOGGER.debug("Restored entity 'Incidents' state to: %s", self._state)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
f"{FIRESERVICEROTA_DOMAIN}_{self._entry_id}_update",
self.coordinator_update,
)
)
@callback
def coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
data = self._coordinator.websocket.incident_data()
if not data or "body" not in data:
return
self._state = data["body"]
self._state_attributes = data
self.async_write_ha_state()

View File

@ -0,0 +1,29 @@
{
"config": {
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"url": "Website"
}
},
"reauth": {
"description": "Authentication tokens baceame invalid, login to recreate them.",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
}
}

View File

@ -0,0 +1,30 @@
{
"config": {
"step": {
"user": {
"title": "FireServiceRota",
"data": {
"password": "Password",
"username": "Username",
"url": "Website"
}
},
"reauth": {
"description": "Authentication tokens became invalid, login to recreate them.",
"data": {
"password": "Password"
}
}
},
"error": {
"invalid_auth": "Invalid authentication."
},
"abort": {
"already_configured": "Account is already configured",
"reauth_successful": "Re-authentication was successful"
},
"create_entry": {
"default": "Successfully authenticated"
}
}
}

View File

@ -58,6 +58,7 @@ FLOWS = [
"enocean",
"epson",
"esphome",
"fireservicerota",
"flick_electric",
"flo",
"flume",

View File

@ -1381,6 +1381,9 @@ pyeverlights==0.1.0
# homeassistant.components.fido
pyfido==2.1.1
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.40
# homeassistant.components.flexit
pyflexit==0.3

View File

@ -684,6 +684,9 @@ pyeverlights==0.1.0
# homeassistant.components.fido
pyfido==2.1.1
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.40
# homeassistant.components.flume
pyflume==0.5.5

View File

@ -0,0 +1 @@
"""Tests for the FireServiceRota integration."""

View File

@ -0,0 +1,114 @@
"""Test the FireServiceRota config flow."""
from pyfireservicerota import InvalidAuthError
from homeassistant import data_entry_flow
from homeassistant.components.fireservicerota.const import ( # pylint: disable=unused-import
DOMAIN,
)
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from tests.async_mock import patch
from tests.common import MockConfigEntry
MOCK_CONF = {
CONF_USERNAME: "my@email.address",
CONF_PASSWORD: "mypassw0rd",
CONF_URL: "www.brandweerrooster.nl",
}
MOCK_DATA = {
"auth_implementation": DOMAIN,
CONF_URL: MOCK_CONF[CONF_URL],
CONF_USERNAME: MOCK_CONF[CONF_USERNAME],
"token": {
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 1234,
"refresh_token": "test-refresh-token",
"created_at": 4321,
},
}
MOCK_TOKEN_INFO = {
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 1234,
"refresh_token": "test-refresh-token",
"created_at": 4321,
}
async def test_show_form(hass):
"""Test that the form is served with no input."""
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_abort_if_already_setup(hass):
"""Test abort if already setup."""
entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONF, unique_id=MOCK_CONF[CONF_USERNAME]
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_CONF
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
async def test_invalid_credentials(hass):
"""Test that invalid credentials throws an error."""
with patch(
"homeassistant.components.fireservicerota.FireServiceRota.request_tokens",
side_effect=InvalidAuthError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_CONF
)
assert result["errors"] == {"base": "invalid_auth"}
async def test_step_user(hass):
"""Test the start of the config flow."""
with patch(
"homeassistant.components.fireservicerota.config_flow.FireServiceRota"
) as MockFireServiceRota, patch(
"homeassistant.components.fireservicerota.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.fireservicerota.async_setup_entry",
return_value=True,
) as mock_setup_entry:
mock_fireservicerota = MockFireServiceRota.return_value
mock_fireservicerota.request_tokens.return_value = MOCK_TOKEN_INFO
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}, data=MOCK_CONF
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == MOCK_CONF[CONF_USERNAME]
assert result["data"] == {
"auth_implementation": "fireservicerota",
CONF_URL: "www.brandweerrooster.nl",
CONF_USERNAME: "my@email.address",
"token": {
"access_token": "test-access-token",
"token_type": "Bearer",
"expires_in": 1234,
"refresh_token": "test-refresh-token",
"created_at": 4321,
},
}
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1