Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions custom_components/kr_component_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
elif etype == ENTRY_SAFETY_ALERT:
from .safety_alert.coordinator import SafetyAlertCoordinator
regions = entry.data.get("regions", [])
# Backward-compat: single area_code schema (older saved entries).
if not regions and entry.data.get("area_code"):
regions = [{
"code": entry.data["area_code"],
"name": entry.data.get("area_name", ""),
"code2": entry.data.get("area_code2"),
"code3": entry.data.get("area_code3"),
}]
# Backward-compat: even older area_codes (plural, bare list of strings)
# schema — produced 17-sensor entity layout that's now orphaned. Map
# each bare code into the current {"code", "name"} shape so the entry
# auto-heals on next HA start instead of staying empty.
if not regions and entry.data.get("area_codes"):
legacy = entry.data["area_codes"]
if isinstance(legacy, list):
regions = [
{"code": c, "name": ""} if isinstance(c, str)
else {"code": c.get("code", ""), "name": c.get("name", "")}
for c in legacy if c
]
coordinators = {}
for region in regions:
c = SafetyAlertCoordinator(
Expand Down
134 changes: 92 additions & 42 deletions custom_components/kr_component_kit/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,53 +390,103 @@ async def async_step_disaster(self, user_input=None) -> FlowResult:
# ══════════ 안전알림 ══════════

async def async_step_safety_alert(self, user_input=None) -> FlowResult:
"""Select regions for safety alerts (시도 + 시군구)."""
"""Step 1/3 — 시도 선택.

Cascading 시도 → 시군구 → 읍면동 dropdown via safekorea.go.kr region API.
Restores the pre-coordinator-migration flow that allowed per-동 alerts
(e.g. "경기도 시흥시 은행동").
"""
from .safety_alert.region_api import SafetyAlertRegionApiClient
errors: dict[str, str] = {}
sido_map = {
"1100000000": "서울특별시", "2600000000": "부산광역시",
"2700000000": "대구광역시", "2800000000": "인천광역시",
"2900000000": "광주광역시", "3000000000": "대전광역시",
"3100000000": "울산광역시", "3600000000": "세종특별자치시",
"4100000000": "경기도", "5100000000": "강원특별자치도",
"4300000000": "충청북도", "4400000000": "충청남도",
"4500000000": "전북특별자치도", "4600000000": "전라남도",
"4700000000": "경상북도", "4800000000": "경상남도",
"5000000000": "제주특별자치도",
}
# 서울 자치구
seoul_gu = {
"1111000000": "종로구", "1114000000": "중구", "1117000000": "용산구",
"1120000000": "성동구", "1121500000": "광진구", "1123000000": "동대문구",
"1126000000": "중랑구", "1129000000": "성북구", "1130500000": "강북구",
"1132000000": "도봉구", "1135000000": "노원구", "1138000000": "은평구",
"1141000000": "서대문구", "1144000000": "마포구", "1147000000": "양천구",
"1150000000": "강서구", "1153000000": "구로구", "1154500000": "금천구",
"1156000000": "영등포구", "1159000000": "동작구", "1162000000": "관악구",
"1165000000": "서초구", "1168000000": "강남구", "1171000000": "송파구",
"1174000000": "강동구",
}
all_regions = {**sido_map, **seoul_gu}
region_opts = [SelectOptionDict(value=k, label=v) for k, v in all_regions.items()]
client = SafetyAlertRegionApiClient()
sido_list = await client.async_get_sido_list()
sido_opts = [SelectOptionDict(value=s["code"], label=s["name"])
for s in sido_list]
if user_input is not None:
areas = user_input.get("area_codes", [])
if not isinstance(areas, list):
areas = [areas]
if not areas:
errors["base"] = "no_selection"
else:
region_items = [{"code": c, "name": all_regions.get(c, c)} for c in areas]
await self.async_set_unique_id(
f"{ENTRY_SAFETY_ALERT}_" + "_".join(sorted(areas)))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="안전알림",
data={CONF_ENTRY_TYPE: ENTRY_SAFETY_ALERT, "regions": region_items})
sido_code = user_input["sido_code"]
sido_name = next((s["name"] for s in sido_list
if s["code"] == sido_code), sido_code)
self._sa_sido_code = sido_code
self._sa_sido_name = sido_name
return await self.async_step_safety_alert_sgg()
return self.async_show_form(step_id="safety_alert", data_schema=vol.Schema({
vol.Required("area_codes"): SelectSelector(
SelectSelectorConfig(options=region_opts, multiple=True,
vol.Required("sido_code"): SelectSelector(
SelectSelectorConfig(options=sido_opts,
mode=SelectSelectorMode.DROPDOWN)),
}), errors=errors)

async def async_step_safety_alert_sgg(self, user_input=None) -> FlowResult:
"""Step 2/3 — 시군구 선택 (또는 시도 전체)."""
from .safety_alert.region_api import SafetyAlertRegionApiClient
errors: dict[str, str] = {}
client = SafetyAlertRegionApiClient()
sgg_list = await client.async_get_sgg_list(self._sa_sido_code) or []
sgg_opts = [SelectOptionDict(value="", label=f"{self._sa_sido_name} 전체 (시군구 미지정)")]
sgg_opts.extend(SelectOptionDict(value=s["code"], label=s["name"])
for s in sgg_list)
if user_input is not None:
sgg_code = user_input.get("sgg_code", "")
if not sgg_code:
# 시군구 미지정 — 시도 단위로 등록 종료
return await self._finish_safety_alert(sgg_code="", emd_code="")
sgg_name = next((s["name"] for s in sgg_list
if s["code"] == sgg_code), sgg_code)
self._sa_sgg_code = sgg_code
self._sa_sgg_name = sgg_name
return await self.async_step_safety_alert_emd()
return self.async_show_form(step_id="safety_alert_sgg", data_schema=vol.Schema({
vol.Optional("sgg_code", default=""): SelectSelector(
SelectSelectorConfig(options=sgg_opts,
mode=SelectSelectorMode.DROPDOWN)),
}), errors=errors)

async def async_step_safety_alert_emd(self, user_input=None) -> FlowResult:
"""Step 3/3 — 읍면동 선택 (옵션, 미지정 가능)."""
from .safety_alert.region_api import SafetyAlertRegionApiClient
errors: dict[str, str] = {}
client = SafetyAlertRegionApiClient()
emd_list = await client.async_get_emd_list(
self._sa_sido_code, self._sa_sgg_code) or []
emd_opts = [SelectOptionDict(value="", label=f"{self._sa_sgg_name} 전체 (읍면동 미지정)")]
emd_opts.extend(SelectOptionDict(value=e["code"], label=e["name"])
for e in emd_list)
if user_input is not None:
return await self._finish_safety_alert(
sgg_code=self._sa_sgg_code,
emd_code=user_input.get("emd_code", ""))
return self.async_show_form(step_id="safety_alert_emd", data_schema=vol.Schema({
vol.Optional("emd_code", default=""): SelectSelector(
SelectSelectorConfig(options=emd_opts,
mode=SelectSelectorMode.DROPDOWN)),
}), errors=errors)

async def _finish_safety_alert(self, sgg_code: str, emd_code: str) -> FlowResult:
"""Build the region entry and create the config entry."""
# Compose a friendly title — e.g. "안전알림 (경기도 시흥시 은행동)"
parts = [self._sa_sido_name]
if sgg_code:
parts.append(self._sa_sgg_name)
if emd_code:
# emd name is whatever the user just selected; we don't have it
# cached, but the title is cosmetic — sgg-level granularity is
# enough for the device label.
pass
title_region = " ".join(parts)
region_item = {
"code": self._sa_sido_code,
"name": title_region,
"code2": sgg_code or None,
"code3": emd_code or None,
}
unique_parts = [self._sa_sido_code, sgg_code or "x", emd_code or "x"]
await self.async_set_unique_id(
f"{ENTRY_SAFETY_ALERT}_" + "_".join(unique_parts))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"안전알림 ({title_region})",
data={CONF_ENTRY_TYPE: ENTRY_SAFETY_ALERT,
"regions": [region_item]})

# ══════════ 한전 (KEPCO) ══════════

async def async_step_kepco(self, user_input=None) -> FlowResult:
Expand Down Expand Up @@ -684,7 +734,7 @@ async def async_step_kma_weather(self, user_input=None) -> FlowResult:
sido_opts = [SelectOptionDict(value=k, label=k) for k in SIDO_LIST.keys()]
return self.async_show_form(step_id="kma_weather", data_schema=vol.Schema({
vol.Required("api_key"): str,
vol.Required("sido"): SelectSelector(
vol.Required("sido_code"): SelectSelector(
SelectSelectorConfig(options=sido_opts,
mode=SelectSelectorMode.DROPDOWN)),
}), errors=errors)
Expand Down
2 changes: 1 addition & 1 deletion custom_components/kr_component_kit/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/redchupa/kr_component_kit/issues",
"requirements": ["curl_cffi>=0.7.0", "beautifulsoup4>=4.12.0"],
"version": "4.2.16"
"version": "4.2.17"
}
18 changes: 16 additions & 2 deletions custom_components/kr_component_kit/safety_alert/api.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
"""Safety Alert API client for Home Assistant integration."""
"""Safety Alert API client for Home Assistant integration.

Note: ``curl_cffi`` is imported lazily inside the request method, not at
module import time. Importing curl_cffi triggers disk reads (listdir on
site-packages, read_text on its METADATA) which Home Assistant flags as
a blocking call inside the event loop during ``async_setup_entry``. The
lazy import defers those file operations until the first actual HTTP
request, by which time HA can run them on the executor without
breaking the loop.
"""

from __future__ import annotations

import re
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional

import curl_cffi
from bs4 import BeautifulSoup

from .exceptions import SafetyAlertConnectionError
Expand Down Expand Up @@ -48,6 +56,12 @@ async def async_get_safety_alerts(
"endDate": end_date.strftime("%Y-%m-%d"),
}

# Lazy import — see module docstring. Importing curl_cffi at module
# top-level runs blocking I/O (listdir, read_text on METADATA) which
# HA detects and warns about during async_setup_entry. Deferring to
# the first request keeps event-loop-safe.
import curl_cffi

try:
async with curl_cffi.AsyncSession(impersonate="chrome120") as session:
response = await session.get(
Expand Down
16 changes: 14 additions & 2 deletions custom_components/kr_component_kit/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,21 @@
}
},
"safety_alert": {
"title": "Safety Alert",
"title": "Safety Alert — Step 1/3: Sido",
"data": {
"area_codes": "Regions (시도/시군구)"
"sido_code": "Sido (province)"
}
},
"safety_alert_sgg": {
"title": "Safety Alert — Step 2/3: Sgg (leave blank for sido-wide)",
"data": {
"sgg_code": "Sgg (city/county/district)"
}
},
"safety_alert_emd": {
"title": "Safety Alert — Step 3/3: Emd (leave blank for sgg-wide)",
"data": {
"emd_code": "Emd (town/village)"
}
},
"kepco": {
Expand Down
18 changes: 15 additions & 3 deletions custom_components/kr_component_kit/translations/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,21 @@
}
},
"safety_alert": {
"title": "안전알림 설정",
"title": "안전알림 — 1/3단계: 시도 선택",
"data": {
"area_codes": "지역 (시도/시군구)"
"sido_code": "시도"
}
},
"safety_alert_sgg": {
"title": "안전알림 — 2/3단계: 시군구 선택 (미지정 시 시도 단위 알림)",
"data": {
"sgg_code": "시군구"
}
},
"safety_alert_emd": {
"title": "안전알림 — 3/3단계: 읍면동 선택 (미지정 시 시군구 단위 알림)",
"data": {
"emd_code": "읍면동"
}
},
"kepco": {
Expand Down Expand Up @@ -193,7 +205,7 @@
}
},
"error": {
"cannot_connect": "API에 연결할 수 없습니다.",
"cannot_connect": "API에 연결할 수 없습니다. ({error})",
"invalid_api_key": "API 키가 유효하지 않습니다.",
"no_schools_found": "학교를 찾을 수 없습니다.",
"no_selection": "하나 이상 선택하세요.",
Expand Down
Loading