From b32cf38572617b513ca864bc0f16ea953933f29a Mon Sep 17 00:00:00 2001 From: redchupa Date: Thu, 14 May 2026 02:44:14 +0900 Subject: [PATCH 1/3] fix: defer curl_cffi import, add ko translation placeholder, migrate legacy safety_alert schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes discovered while writing the README guide: 1. safety_alert/api.py — defer ``import curl_cffi`` from module top-level to inside the request method. Importing curl_cffi triggers blocking disk I/O (listdir on site-packages, read_text on METADATA) which HA detects during async_setup_entry and warns: Detected blocking call to listdir/read_text/open inside the event loop by custom integration 'kr_component_kit' at safety_alert/api.py, line 9: import curl_cffi Lazy import defers those reads to the first HTTP request, by which point the event loop can run them on the executor cleanly. 2. translations/ko.json — add ``{error}`` placeholder to the cannot_connect error string so it matches en.json/ja.json. HA emits a validation error otherwise: Validation of translation placeholders for localized (ko) string component.kr_component_kit.config.error.cannot_connect failed: (set() != {'error'}) 3. __init__.py — add backward-compat path for an even older safety_alert entry.data schema that stored regions under ``area_codes`` (plural, bare list). Entries on that legacy schema produced a 17-sensor entity layout that's now orphaned (state unavailable) and sets neither ``regions`` nor ``area_code`` (single), so they fall through both existing branches and end up with regions=[] (no coordinator setup, no entity creation). The new mapping turns each legacy code into the current {"code", "name"} shape so the entry auto-heals on the next HA start. Co-Authored-By: Claude Opus 4.7 --- custom_components/kr_component_kit/__init__.py | 13 +++++++++++++ .../kr_component_kit/safety_alert/api.py | 18 ++++++++++++++++-- .../kr_component_kit/translations/ko.json | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/custom_components/kr_component_kit/__init__.py b/custom_components/kr_component_kit/__init__.py index 2161e5e..586f3f3 100644 --- a/custom_components/kr_component_kit/__init__.py +++ b/custom_components/kr_component_kit/__init__.py @@ -86,6 +86,7 @@ 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"], @@ -93,6 +94,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "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( diff --git a/custom_components/kr_component_kit/safety_alert/api.py b/custom_components/kr_component_kit/safety_alert/api.py index 63a3c4c..d9bd0bd 100644 --- a/custom_components/kr_component_kit/safety_alert/api.py +++ b/custom_components/kr_component_kit/safety_alert/api.py @@ -1,4 +1,13 @@ -"""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 @@ -6,7 +15,6 @@ from datetime import datetime, timedelta from typing import Dict, Any, List, Optional -import curl_cffi from bs4 import BeautifulSoup from .exceptions import SafetyAlertConnectionError @@ -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( diff --git a/custom_components/kr_component_kit/translations/ko.json b/custom_components/kr_component_kit/translations/ko.json index a83e17f..d5b4c83 100644 --- a/custom_components/kr_component_kit/translations/ko.json +++ b/custom_components/kr_component_kit/translations/ko.json @@ -193,7 +193,7 @@ } }, "error": { - "cannot_connect": "API에 연결할 수 없습니다.", + "cannot_connect": "API에 연결할 수 없습니다. ({error})", "invalid_api_key": "API 키가 유효하지 않습니다.", "no_schools_found": "학교를 찾을 수 없습니다.", "no_selection": "하나 이상 선택하세요.", From 8ce94ccdd1df24ec8689b5b5ee5b9e40ff4fa9a0 Mon Sep 17 00:00:00 2001 From: redchupa Date: Thu, 14 May 2026 02:52:12 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(safety=5Falert):=20restore=20cascading?= =?UTF-8?q?=20=EC=8B=9C=EB=8F=84=E2=86=92=EC=8B=9C=EA=B5=B0=EA=B5=AC?= =?UTF-8?q?=E2=86=92=EC=9D=8D=EB=A9=B4=EB=8F=99=20dropdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical regression fix. Previous config_flow exposed only: - 17 광역시도 (sido-level) - Seoul gu (autonomous districts within Seoul only) This meant users outside Seoul could only register at sido granularity — e.g. "경기도" but not "경기도 시흥시" — a hard downgrade from earlier versions of the integration that used safekorea.go.kr's region API to cascade through sido → sgg → emd. The region client (safety_alert/region_api.py) was still in the codebase but no longer wired into config_flow. This commit restores the three-step cascading flow: Step 1 (safety_alert): sido dropdown (hardcoded 17개) Step 2 (safety_alert_sgg): sgg dropdown for selected sido (live API) Step 3 (safety_alert_emd): emd dropdown for selected sgg (live API) Each step's "leave blank" option lets users stop at sido- or sgg-level granularity if they don't want per-동 alerts. Output is stored in the existing `regions[]` schema with `code` / `code2` / `code3` matching the __init__.py setup path that's been there all along. Field names sido_code / sgg_code / emd_code match the en.json labels that were already in the codebase from the original cascading flow (those translation keys were never removed even though config_flow had been simplified to a single dropdown). Co-Authored-By: Claude Opus 4.7 --- .../kr_component_kit/config_flow.py | 134 ++++++++++++------ .../kr_component_kit/strings.json | 16 ++- .../kr_component_kit/translations/ko.json | 16 ++- 3 files changed, 120 insertions(+), 46 deletions(-) diff --git a/custom_components/kr_component_kit/config_flow.py b/custom_components/kr_component_kit/config_flow.py index 0af0860..e189090 100644 --- a/custom_components/kr_component_kit/config_flow.py +++ b/custom_components/kr_component_kit/config_flow.py @@ -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: @@ -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) diff --git a/custom_components/kr_component_kit/strings.json b/custom_components/kr_component_kit/strings.json index bd78311..aa73d95 100644 --- a/custom_components/kr_component_kit/strings.json +++ b/custom_components/kr_component_kit/strings.json @@ -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": { diff --git a/custom_components/kr_component_kit/translations/ko.json b/custom_components/kr_component_kit/translations/ko.json index d5b4c83..6e2af18 100644 --- a/custom_components/kr_component_kit/translations/ko.json +++ b/custom_components/kr_component_kit/translations/ko.json @@ -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": { From cd806a98500dd84fc7cf2982f0a8a1b1ea11cc32 Mon Sep 17 00:00:00 2001 From: redchupa Date: Thu, 14 May 2026 02:54:22 +0900 Subject: [PATCH 3/3] chore: bump version to 4.2.17 Bundle with PR #16 fixes (curl_cffi lazy import, ko translation placeholder, legacy schema migration, cascading region dropdown restoration) for HACS update notification. Co-Authored-By: Claude Opus 4.7 --- custom_components/kr_component_kit/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/kr_component_kit/manifest.json b/custom_components/kr_component_kit/manifest.json index d580266..823b10a 100644 --- a/custom_components/kr_component_kit/manifest.json +++ b/custom_components/kr_component_kit/manifest.json @@ -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" }