이제 실제로 통합 구성요소(Integration)을 만들어서 홈어시스턴트(HomeAssistant)의 기능을 확장해보도록 하겠습니다.
이번 글에서는 예전에 제가 만든 통합 구성요소(https://github.com/huimang2/covid19_kr)를 참고하여 코로나 현황을 sensor 플랫폼을 통해 표시하는 통합 구성요소를 만들어 보도록 하겠습니다.
첨부파일에 완성된 통합 구성요소 압축 파일을 올려두었습니다.
도메인명은 cov_situation으로 하고 api 데이터는 서울특별시 코로나 발생현황 페이지(https://www.seoul.go.kr/coronaV/coronaStatus.do)를 6시간에 한번씩 크롤링하도록 하겠습니다.
우선 커스텀 컴포넌트 폴더로 이동하고 cov_situation 폴더를 만듭시다.
$ cd /usr/share/hassio/homeassistant/custom_components && sudo mkdir cov_situation |
통합 구성요소 설정 파일(manifest.json)을 만들고 다음과 같이 작성합니다.
$ sudo touch manifest.json && sudo xed manifest.json |
requirements에는 코어에 존재하지 않는 의존성 pip 모듈 리스트를 작성합니다. 크롤링에 필요한 beautifulsoup4 모듈을 입력해줍시다.
다음으로 상수를 const.py 파일에 따로 작성하겠습니다.
$ sudo touch const.py && sudo xed const.py |
컴포넌트 파일(__init__.py)을 만들고 다음과 같이 작성합니다.
$ sudo touch __init__.py && sudo xed __init__.py |
"""cov_situation integration component""" from __future__ import annotations
import async_timeout import logging
from datetime import datetime, timedelta
from bs4 import BeautifulSoup
from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ( ATTR_CUMULATIVE_CASE, ATTR_NEW_CASE, CONF_CITY, DOMAIN, PLATFORMS, URL )
_LOGGER = logging.getLogger(__name__)
async def async_setup( hass: HomeAssistant, config: dict) -> bool: """Set up the cov_situation integration component.""" hass.data.setdefault(DOMAIN, {}) return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the cov_situation integration component from a config entry."""
if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=entry.data.get(CONF_CITY))
coordinator = await get_coordinator(hass)
if not coordinator.last_update_success: await coordinator.async_config_entry_first_refresh()
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def get_coordinator(hass: HomeAssistant) -> DataUpdateCoordinator: """Get the data update coordinator."""
if hass.data[DOMAIN]: return hass.data[DOMAIN]
async def async_get_data(): with async_timeout.timeout(30):
try: hdr = { "User-Agent": ( "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/78.0.3904.70 safari/537.36" ) }
session = async_get_clientsession(hass)
res = await session.get(URL, headers=hdr, timeout=30) res.raise_for_status()
cr = BeautifulSoup(await res.text(), "html.parser")
except Exception as ex: _LOGGER.error("Failed to crawling Error: %s", ex) raise
selector = lambda a, b: [x.text for x in cr.select( "#move-cont1 > div:nth-of-type(2) > table.tstyle-status.pc.pc-table > tbody > tr:nth-of-type({0}) > {1}".format(a,b))]
city_list = selector("3n-2", "th") cumulative_cases = selector("3n-1", "td") new_cases = selector("3n", "td")
data = { city_list[n]: { ATTR_CUMULATIVE_CASE: cumulative_cases[n], ATTR_NEW_CASE: new_cases[n] } for n in range(len(city_list)) }
return data
hass.data[DOMAIN] = DataUpdateCoordinator( hass, logging.getLogger(__name__), name=DOMAIN, update_method=async_get_data, update_interval=timedelta(hours=6), )
await hass.data[DOMAIN].async_refresh()
return hass.data[DOMAIN]
|
필요한 모듈을 import 합니다.
from __future__ import annotations
import async_timeout import logging
from datetime import datetime, timedelta
from bs4 import BeautifulSoup
from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ( ATTR_CUMULATIVE_CASE, ATTR_NEW_CASE, CONF_CITY, DOMAIN, PLATFORMS, URL )
_LOGGER = logging.getLogger(__name__) |
홈어시스턴트가 시작되면 컴포넌트의 async_setup 함수가 실행됩니다. setdefault 함수를 통해 코어의 data 필드를 비어있는 딕셔너리로 초기화합니다.
async def async_setup( hass: HomeAssistant, config: dict) -> bool: """Set up the cov_situation integration component.""" hass.data.setdefault(DOMAIN, {}) return True |
엔트리가 추가될때 async_setup_entry 함수가 실행되고, 삭제될때 async_unload_entry 함수가 실행됩니다. 엔트리의 unique_id가 없으면 async_update_entry 함수로 unique_id를 설정해줍니다. get_coordinator 함수를 통해 데이터 코디네이터 객체를 형성합니다. 코디네이터에 대해서는 아래에 설명하도록 하겠습니다.
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the cov_situation integration component from a config entry."""
if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=entry.data.get(CONF_CITY))
coordinator = await get_coordinator(hass)
if not coordinator.last_update_success: await coordinator.async_config_entry_first_refresh()
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) |
데이터 코디네이터를 생성하기 위한 함수를 정의합니다. 함수 이름은 get_coordinator이고, 홈어시스턴트 객체를 매개변수로 받으며, 데이터 코디네이터 객체를 리턴합니다. async def get_coordinator(hass: HomeAssistant) -> DataUpdateCoordinator: """Get the data update coordinator.""" |
데이터 코디네이터 객체는 코어의 DataUpdateCoordinator 모듈을 통해 생성할 수 있습니다. polling 방식의 api 데이터는 코디네이터 객체를 통해 관리가능합니다. 일정 시간을 간격으로 지정된 함수를 실행하여 데이터를 갱신할 수 있습니다.
우선 데이터 코디네이터 객체가 존재하는지 확인하고 존재한다면 해당 객체를 리턴합니다.
if hass.data[DOMAIN]: return hass.data[DOMAIN] |
코디네이터에서 실행할 함수를 작성합시다. 함수 이름은 async_get_data이고 비동기 함수입니다.
async def async_get_data(): with async_timeout.timeout(30): |
코어의 async_get_clientsession 함수를 통해 세션을 생성하고 크롤링 페이지에 요청을 보내고 응답 객체를 받습니다. BeautifulSoup 모듈을 통해 응답 객체에서 필요한 정보를 추출하고 해당 정보를 리턴합니다. BeautifulSoup 모듈에 대해서는 자세히 설명하지 않겠습니다.
try: hdr = { "User-Agent": ( "mozilla/5.0 (windows nt 10.0; win64; x64) applewebkit/537.36 (khtml, like gecko) chrome/78.0.3904.70 safari/537.36" ) }
session = async_get_clientsession(hass)
res = await session.get(URL, headers=hdr, timeout=30) res.raise_for_status()
cr = BeautifulSoup(await res.text(), "html.parser")
except Exception as ex: _LOGGER.error("Failed to crawling Error: %s", ex) raise
selector = lambda a, b: [x.text for x in cr.select( "#move-cont1 > div:nth-of-type(2) > table.tstyle-status.pc.pc-table > tbody > tr:nth-of-type({0}) > {1}".format(a,b))]
city_list = selector("3n-2", "th") cumulative_cases = selector("3n-1", "td") new_cases = selector("3n", "td")
data = { city_list[n]: { ATTR_CUMULATIVE_CASE: cumulative_cases[n], ATTR_NEW_CASE: new_cases[n] } for n in range(len(city_list)) }
return data |
DataUpdateCoordinator 함수를 통해 데이터 코디네이터 객체를 생성하고 해당 객체를 리턴해줍시다. 리턴하기전에 코디네이터 객체의 async_refresh 메쏘드를 통해 데이터를 갱신 해줍시다. name에는 코디네이터를 구분하기 위한 이름을, update_method에는 실행할 함수를, update_interval에는 갱신 간격을 매개변수로 입력합니다.
hass.data[DOMAIN] = DataUpdateCoordinator( hass, logging.getLogger(__name__), name=DOMAIN, update_method=async_get_data, update_interval=timedelta(hours=6), )
await hass.data[DOMAIN].async_refresh()
return hass.data[DOMAIN] |
다음으로 엔트리 생성 파일(config_flow.py)을 작성합시다.
$ sudo touch config_flow.py && sudo xed config_flow.py |
"""cov_situation integration config entry"""
import voluptuous as vol
from homeassistant.helpers import config_validation as cv from homeassistant import config_entries
from .const import DOMAIN, CITY_LIST, CONF_CITY
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """cov_situation integration ConfigFlow"""
async def async_step_user(self, user_input):
if user_input is not None:
city = user_input.get(CONF_CITY)
await self.async_set_unique_id(city) self._abort_if_unique_id_configured()
return self.async_create_entry(title=city, data=user_input)
return self.async_show_form( step_id="user", data_schema=vol.Schema({ vol.Required(CONF_CITY): vol.In(CITY_LIST) }) )
|
city값을 설정값으로 받도록 작성합니다. 스키마를 작성할때 voluptuos 모듈의 In 함수에 리스트를 넘겨주면 목록형식의 폼이 생성됩니다. 아래쪽에 엔트리를 생성할때 사진을 참고해주세요.
저장하고 backend localization을 위해 strings.json 파일을 다음과 같이 작성하세요.
$ sudo touch strings.json && sudo xed strings.json |
{ "title": "COVID-19 Situation", "config": { "step": { "user": { "title": "Pick a city to monitor", "data": { "city": "City" } } }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } } } |
저장하고 frotend localization을 위해 translations/ko.json 파일을 다음과 같이 작성하세요.
{ "title": "\uC11C\uC6B8\uD2B9\uBCC4\uC2DC \uCF54\uB85C\uB098 \uD604\uD669", "config": { "step": { "user": { "title": "\uC11C\uC6B8\uD2B9\uBCC4\uC2DC \uD589\uC815\uAD6C\uB97C \uC120\uD0DD\uD558\uC138\uC694.", "data": { "city": "\uD589\uC815\uAD6C" } } }, "abort": { "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4", "already_configured": "\uc11c\ube44\uc2a4\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" } } } |
마지막으로 센서 플랫폼 파일(sensor.py)을 작성해주세요.
$ sudo touch sensor.py && sudo xed sensor.py |
"""cov_situation integration sensor platform""" from __future__ import annotations
import re
from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import get_coordinator from .const import ( ATTR_CUMULATIVE_CASE, ATTR_NEW_CASE, BRAND, CONF_CITY, DOMAIN, MODEL, SW_VERSION )
async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the cov_situation integration sensor platform from a config entry."""
coordinator = await get_coordinator(hass)
city = config_entry.data.get(CONF_CITY) async_add_entities( CoronaSensor(coordinator, city, sensor_type) for sensor_type in [ATTR_CUMULATIVE_CASE, ATTR_NEW_CASE] if coordinator.data.get(city) )
class CoronaSensor(CoordinatorEntity, SensorEntity): """sensor platform class.""" def __init__(self, coordinator, city, sensor_type): """Initialize coronavirus sensor.""" super().__init__(coordinator)
self.city = city self.sensor_type = sensor_type
self._attr_unit_of_measurement = "명" self._attr_unique_id = f"{city}-{sensor_type}"
@property def device_info(self): """Return device registry information for this entity.""" return { "connections": {(self.city, self.unique_id)}, "identifiers": { ( DOMAIN, self.city, ) }, "manufacturer": BRAND, "model": f"{MODEL} {SW_VERSION}", "name": f"{self.city} 확진자 현황", "sw_version": SW_VERSION, "via_device": (DOMAIN, self.city), "entry_type": "service", }
@property def icon(self): """Return the sensor icon.""" if self.sensor_type == ATTR_CUMULATIVE_CASE: return "mdi:emoticon-neutral-outline" else: return "mdi:emoticon-sad-outline"
@property def name(self): """Return the sensor name.""" if self.sensor_type == ATTR_CUMULATIVE_CASE: return f"{self.city} 코로나 누적 확진자" else: return f"{self.city} 신규 확진자"
@property def state(self): """Return the state of the sensor.""" return "".join(re.split("[^0-9]", self.coordinator.data[self.city][self.sensor_type])) or 0
|
의존성 모듈을 import 합니다.
"""cov_situation integration sensor platform""" from __future__ import annotations
import re
from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import get_coordinator from .const import ( ATTR_CUMULATIVE_CASE, ATTR_NEW_CASE, BRAND, CONF_CITY, DOMAIN, MODEL, SW_VERSION ) |
엔트리를 통해 플랫폼을 생성할 때 async_setup_entry 함수가 실행됩니다. async_add_entities 함수를 통해 구성요소(entity)를 생성하는데 누적 확진자와 신규 확진자 2개의 센서를 생성하도록 합시다.
async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the cov_situation integration sensor platform from a config entry."""
coordinator = await get_coordinator(hass)
city = config_entry.data.get(CONF_CITY) async_add_entities( CoronaSensor(coordinator, city, sensor_type) for sensor_type in [ATTR_CUMULATIVE_CASE, ATTR_NEW_CASE] if coordinator.data.get(city) ) |
CoordinatorEntity 클래스를 상속하는 센서 클래스를 만듭시다. CoordinatorEntity 클래스는 코디네이터 객체를 통해 구성요소 상태를 관리할 수 있도록 하는 클래스입니다. 생성자 함수 __init__에서 super 함수를 통해 부모 클래스 생성자에 코디네이터 객체를 넘겨주면 coordinator 필드에 코디네이터 객체가 할당됩니다. 아래 프로퍼티에 대해서는 자세히 설명하지 않겠습니다.
class CoronaSensor(CoordinatorEntity, SensorEntity): """sensor platform class.""" def __init__(self, coordinator, city, sensor_type): """Initialize coronavirus sensor.""" super().__init__(coordinator)
self.city = city self.sensor_type = sensor_type
self._attr_unit_of_measurement = "명" self._attr_unique_id = f"{city}-{sensor_type}"
@property def device_info(self): """Return device registry information for this entity.""" return { "connections": {(self.city, self.unique_id)}, "identifiers": { ( DOMAIN, self.city, ) }, "manufacturer": BRAND, "model": f"{MODEL} {SW_VERSION}", "name": f"{self.city} 확진자 현황", "sw_version": SW_VERSION, "via_device": (DOMAIN, self.city), "entry_type": "service", }
@property def icon(self): """Return the sensor icon.""" if self.sensor_type == ATTR_CUMULATIVE_CASE: return "mdi:emoticon-neutral-outline" else: return "mdi:emoticon-sad-outline"
@property def name(self): """Return the sensor name.""" if self.sensor_type == ATTR_CUMULATIVE_CASE: return f"{self.city} 코로나 누적 확진자" else: return f"{self.city} 신규 확진자"
@property def state(self): """Return the state of the sensor.""" return "".join(re.split("[^0-9]", self.coordinator.data[self.city][self.sensor_type])) or 0 |
저장하고 홈어시스턴트 메인화면에서 구성하기 > 서버 제어 > 다시 시작하기 를 누릅니다.
홈어시스턴트 메인화면에서 구성하기 > 통합 구성요소 > 통합 구성요소 추가하기 를 클릭합니다.
서울 특별시 코로나 현황을 클릭하고 행정구를 선택 후 확인을 누르면 엔트리가 생성됩니다.
센서를 만들때 device_info 필드의 identifiers 값은 서비스나 디바이스의 수를 결정합니다. 2개의 센서가 동일한 identifiers 값을 가지도록 생성했으므로 1개의 서비스가 형성된 것을 확인할 수 있습니다.
해당 서비스 정보를 확인해보세요.
동일하게 강동구 엔트리도 만들어 보세요.
|