diff --git a/arcsolve/services/ev_charger/README.md b/arcsolve/services/ev_charger/README.md index 6e62c59..785a1d6 100644 --- a/arcsolve/services/ev_charger/README.md +++ b/arcsolve/services/ev_charger/README.md @@ -53,8 +53,8 @@ GET·**XML**. **서비스키 필수**(공공데이터포털 발급), 키는 ** ## 도구 | 도구 | 설명 | |------|------| -| `evcharger_status(zcode?, zscode?, period?, numOfRows?, pageNo?)` ⭐ | 충전기 실시간 상태(충전중/대기/통신이상/운영중지/점검중/상태미확인 + 상태갱신일시). ⚠️ 약 5분 지연 캐시 | -| `evcharger_info(zcode?, zscode?, numOfRows?, pageNo?)` | 충전소 정보(충전기 타입 코드·주소·위경도·운영기관·이용가능시간) | +| `ev_charger_status(zcode?, zscode?, period?, numOfRows?, pageNo?)` ⭐ | 충전기 실시간 상태(충전중/대기/통신이상/운영중지/점검중/상태미확인 + 상태갱신일시). ⚠️ 약 5분 지연 캐시 | +| `ev_charger_info(zcode?, zscode?, numOfRows?, pageNo?)` | 충전소 정보(충전기 타입 코드·주소·위경도·운영기관·이용가능시간) | > `zcode`(시도)/`zscode`(시군구)는 **행정구역 지역코드**(zcode=행정구역코드 앞 2자리, 예: 11=서울). 둘 다 생략 시 전국. `period`(분)는 상태갱신 조회범위(기본 5·최소 1·최대 10). diff --git a/arcsolve/services/ev_charger/tools.py b/arcsolve/services/ev_charger/tools.py index 1141ccb..e3bf4de 100644 --- a/arcsolve/services/ev_charger/tools.py +++ b/arcsolve/services/ev_charger/tools.py @@ -155,7 +155,7 @@ def register(mcp: FastMCP) -> None: """이 서비스의 도구를 서버에 등록한다.""" @mcp.tool - async def evcharger_status( + async def ev_charger_status( zcode: str | None = None, zscode: str | None = None, period: int = c.DEFAULT_PERIOD, @@ -209,7 +209,7 @@ async def evcharger_status( return "\n".join(lines) @mcp.tool - async def evcharger_info( + async def ev_charger_info( zcode: str | None = None, zscode: str | None = None, numOfRows: int = c.DEFAULT_NUM_OF_ROWS, # noqa: N803 (공식 파라미터명) diff --git a/changelog.d/ev_charger.md b/changelog.d/ev_charger.md index 392d8b2..68f6eaa 100644 --- a/changelog.d/ev_charger.md +++ b/changelog.d/ev_charger.md @@ -1 +1 @@ -- **ev_charger**: 전기차 충전소(한국환경공단 EvCharger) 정보·실시간 상태 읽기 서비스 추가 — 충전기 실시간 상태·충전소 정보 2개 GET 도구(`evcharger_status`/`evcharger_info`, 폴더 `ev_charger`·prefix `evcharger_`), data.go.kr 서비스키는 쿼리 파라미터 `serviceKey`(필수, **Decoding 키** — 이중 인코딩 방지), airkorea·egen과 같은 기관(B552584), 응답은 **XML**이라 `get_text`+`xml.etree`로 파싱(egen 패턴), 봉투 `resultCode != "00"`/게이트웨이 `cmmMsgHeader` 에러 매핑(서비스키/트래픽), 지역코드 `zcode`(시도)·`zscode`(시군구) 선택 필터, `stat` 상태 코드(1통신이상·2충전대기·3충전중·4운영중지·5점검중·9상태미확인) 한글 표시, `chgerType` 타입 코드는 가이드 코드표로 라벨링(미상 코드는 원본 보존), `numOfRows`[10,9999]·`period`[1,10] 클램프, 좌표·상태·플래그는 문자열·결측 보존. ⚠️ 상태는 약 5분 주기 갱신(캐시 스냅샷) — 출력에 지연 명시 +- **ev_charger**: 전기차 충전소(한국환경공단 EvCharger) 정보·실시간 상태 읽기 서비스 추가 — 충전기 실시간 상태·충전소 정보 2개 GET 도구(`ev_charger_status`/`ev_charger_info`, 폴더 `ev_charger`·prefix `ev_charger_`), data.go.kr 서비스키는 쿼리 파라미터 `serviceKey`(필수, **Decoding 키** — 이중 인코딩 방지), airkorea·egen과 같은 기관(B552584), 응답은 **XML**이라 `get_text`+`xml.etree`로 파싱(egen 패턴), 봉투 `resultCode != "00"`/게이트웨이 `cmmMsgHeader` 에러 매핑(서비스키/트래픽), 지역코드 `zcode`(시도)·`zscode`(시군구) 선택 필터, `stat` 상태 코드(1통신이상·2충전대기·3충전중·4운영중지·5점검중·9상태미확인) 한글 표시, `chgerType` 타입 코드는 가이드 코드표로 라벨링(미상 코드는 원본 보존), `numOfRows`[10,9999]·`period`[1,10] 클램프, 좌표·상태·플래그는 문자열·결측 보존. ⚠️ 상태는 약 5분 주기 갱신(캐시 스냅샷) — 출력에 지연 명시 diff --git a/docs/providers.md b/docs/providers.md index 5992bae..bababa2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -333,8 +333,8 @@ - 한국환경공단_전기자동차 충전소 정보 OpenAPI(EvCharger) 상세(base·오퍼레이션·serviceKey/pageNo/numOfRows/period/zcode·getChargerStatus 응답 필드·stat 코드·실시간 5분 갱신): https://www.data.go.kr/data/15076352/openapi.do - 전국전기차충전소표준데이터(정보 필드 한글 라벨 교차참조): https://www.data.go.kr/data/15013115/standard.do - 도구(MVP, 전부 GET·읽기): - - `evcharger_status(zcode?, zscode?, period?, numOfRows?, pageNo?)` ⭐ — `/getChargerStatus` 충전기 실시간 상태(충전중/충전대기/통신이상/운영중지/점검중/상태미확인 + 상태갱신일시). 지역코드 필터. - - `evcharger_info(zcode?, zscode?, numOfRows?, pageNo?)` — `/getChargerInfo` 충전소 정보(충전기 타입 코드·주소·위경도·운영기관·이용가능시간). 지역코드 필터. + - `ev_charger_status(zcode?, zscode?, period?, numOfRows?, pageNo?)` ⭐ — `/getChargerStatus` 충전기 실시간 상태(충전중/충전대기/통신이상/운영중지/점검중/상태미확인 + 상태갱신일시). 지역코드 필터. + - `ev_charger_info(zcode?, zscode?, numOfRows?, pageNo?)` — `/getChargerInfo` 충전소 정보(충전기 타입 코드·주소·위경도·운영기관·이용가능시간). 지역코드 필터. - 응답: 봉투 XML `
` — 본문 페이지네이션. **`resultCode != "00"`이면 에러**(HTTP 200이라도 봉투로 옴; 게이트웨이 키 차단은 `
` 대신 `cmmMsgHeader/returnReasonCode`로 옴 → 30 등 매핑). 상태(`stat`)·타입(`chgerType`)·위경도·플래그(`*Yn`)는 **문자열**·결측 빈 값 → 캐스팅 금지. `stat` 코드: 1통신이상·2충전대기·3충전중·4운영중지·5점검중·9상태미확인(한글 표시). - 제약(공식): `zcode`(시도)·`zscode`(시군구)는 **행정구역 지역코드**(zcode=앞 2자리, 선택). `period`(분, status 전용) 기본 5·[1,10] 클램프. `numOfRows` 기본 100·**[10,9999]** 클램프·`pageNo` 기본 1. - ⚠️ 실시간 지연: `getChargerStatus`는 "실시간"이지만 상류가 **약 5분 주기** 갱신 → 결과는 수 분 지연된 캐시 스냅샷(`statUpdDt`로 갱신시각 확인). 도구 출력 헤더에 "약 5분 지연(캐시 스냅샷)" 명시. diff --git a/docs/services.md b/docs/services.md index 296b044..ce1efa6 100644 --- a/docs/services.md +++ b/docs/services.md @@ -65,8 +65,8 @@ | 도구 | 설명 | |------|------| -| `evcharger_info` | 충전소 정보를 조회한다(GET /getChargerInfo). | -| `evcharger_status` | 충전기 실시간 상태를 조회한다(GET /getChargerStatus). | +| `ev_charger_info` | 충전소 정보를 조회한다(GET /getChargerInfo). | +| `ev_charger_status` | 충전기 실시간 상태를 조회한다(GET /getChargerStatus). | ## feeds — RSS/Atom/RDF 피드 읽기(임의 피드 URL → 메타·최근 항목) 공식 문서: https://www.rssboard.org/rss-specification diff --git a/tests/test_ev_charger_tools.py b/tests/test_ev_charger_tools.py index bfa61e1..a61f807 100644 --- a/tests/test_ev_charger_tools.py +++ b/tests/test_ev_charger_tools.py @@ -67,7 +67,7 @@ async def test_status_request_and_output(tools, monkeypatch, recording_http): http = recording_http(ret=STATUS_XML) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"](zcode="11", zscode="11680") + out = await tools["ev_charger_status"](zcode="11", zscode="11680") assert http.last["url"] == "http://apis.data.go.kr/B552584/EvCharger/getChargerStatus" # 서비스키는 쿼리 파라미터(헤더 아님), Decoding 키 원문 그대로(이중 인코딩 방지). assert http.last["params"]["serviceKey"] == "DECODED_KEY" @@ -86,7 +86,7 @@ async def test_status_request_and_output(tools, monkeypatch, recording_http): async def test_status_omits_region_when_none(tools, monkeypatch, recording_http): http = recording_http(ret=STATUS_XML) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"]() + out = await tools["ev_charger_status"]() assert "zcode" not in http.last["params"] assert "zscode" not in http.last["params"] assert "전국" in out # 지역 미지정 → 전국 @@ -97,7 +97,7 @@ async def test_status_missing_key_no_network(monkeypatch, load_tools, recording_ tools = load_tools(register) http = recording_http(ret=STATUS_XML) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"](zcode="11") + out = await tools["ev_charger_status"](zcode="11") assert "EV_CHARGER_SERVICE_KEY" in out assert "Decoding" in out # 이중 인코딩 함정 안내 assert not http.calls # HTTP 전에 막힘 @@ -106,14 +106,14 @@ async def test_status_missing_key_no_network(monkeypatch, load_tools, recording_ async def test_status_empty_items(tools, monkeypatch, recording_http): http = recording_http(ret=_envelope("", total=0)) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"](zcode="50") + out = await tools["ev_charger_status"](zcode="50") assert "데이터 없음" in out async def test_status_period_clamped(tools, monkeypatch, recording_http): http = recording_http(ret=STATUS_XML) monkeypatch.setattr(f"{MOD}.get_text", http) - await tools["evcharger_status"](period=99) + await tools["ev_charger_status"](period=99) assert http.last["params"]["period"] == 10 # 최대 10으로 클램프 @@ -124,7 +124,7 @@ async def test_info_request_and_output(tools, monkeypatch, recording_http): http = recording_http(ret=INFO_XML) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_info"](zcode="28") + out = await tools["ev_charger_info"](zcode="28") assert http.last["url"].endswith("/getChargerInfo") assert http.last["params"]["zcode"] == "28" assert "period" not in http.last["params"] # period는 status 전용 @@ -145,7 +145,7 @@ async def test_info_unknown_chger_type_preserves_code(tools, monkeypatch, record ) http = recording_http(ret=xml) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_info"](zcode="11") + out = await tools["ev_charger_info"](zcode="11") assert "타입 99" in out assert "99(" not in out # 미상 코드엔 괄호 라벨 없음 @@ -159,7 +159,7 @@ async def test_result_code_30_unregistered_key(tools, monkeypatch, recording_htt ret=_envelope("", result_code="30", result_msg="SERVICE_KEY_IS_NOT_REGISTERED_ERROR") ) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"](zcode="11") + out = await tools["ev_charger_status"](zcode="11") assert "등록되지 않은 서비스키" in out assert "Decoding" in out # 이중 인코딩 힌트 @@ -168,7 +168,7 @@ async def test_gateway_cmmmsgheader_error(tools, monkeypatch, recording_http): # 게이트웨이 차단은
없이 cmmMsgHeader로 온다(HTTP 200) — 30으로 매핑. http = recording_http(ret=GATEWAY_ERROR_XML) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_info"](zcode="11") + out = await tools["ev_charger_info"](zcode="11") assert "등록되지 않은 서비스키" in out @@ -177,21 +177,21 @@ async def test_result_code_22_traffic_limit(tools, monkeypatch, recording_http): ret=_envelope("", result_code="22", result_msg="LIMITED_NUMBER_OF_SERVICE_REQUESTS") ) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"]() + out = await tools["ev_charger_status"]() assert "요청 제한" in out async def test_result_code_03_no_data(tools, monkeypatch, recording_http): http = recording_http(ret=_envelope("", result_code="03", result_msg="NODATA_ERROR")) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_info"](zcode="99") + out = await tools["ev_charger_info"](zcode="99") assert "데이터 없음" in out async def test_unknown_result_code(tools, monkeypatch, recording_http): http = recording_http(ret=_envelope("", result_code="77", result_msg="WEIRD")) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"](zcode="11") + out = await tools["ev_charger_status"](zcode="11") assert "resultCode=77" in out and "WEIRD" in out @@ -201,14 +201,14 @@ async def test_unknown_result_code(tools, monkeypatch, recording_http): async def test_maps_http_401(tools, monkeypatch, recording_http): http = recording_http(exc=UpstreamError(401, {"returnAuthMsg": "SERVICE ACCESS DENIED"})) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"](zcode="11") + out = await tools["ev_charger_status"](zcode="11") assert "401" in out and "EV_CHARGER_SERVICE_KEY" in out async def test_mapped_http_error_does_not_leak_non_dict_detail(tools, monkeypatch, recording_http): http = recording_http(exc=UpstreamError(403, "403 Forbidden")) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_info"](zcode="11") + out = await tools["ev_charger_info"](zcode="11") assert "403" in out assert "" not in out and "" not in out @@ -216,12 +216,12 @@ async def test_mapped_http_error_does_not_leak_non_dict_detail(tools, monkeypatc async def test_unmapped_http_error_500(tools, monkeypatch, recording_http): http = recording_http(exc=UpstreamError(500, {"resultMsg": "INTERNAL"})) monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"]() + out = await tools["ev_charger_status"]() assert "500" in out async def test_maps_xml_parse_error(tools, monkeypatch, recording_http): http = recording_http(ret="<response><body><items><item broken") monkeypatch.setattr(f"{MOD}.get_text", http) - out = await tools["evcharger_status"](zcode="11") + out = await tools["ev_charger_status"](zcode="11") assert "파싱 실패" in out