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
4 changes: 2 additions & 2 deletions arcsolve/services/ev_charger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions arcsolve/services/ev_charger/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (공식 파라미터명)
Expand Down
2 changes: 1 addition & 1 deletion changelog.d/ev_charger.md
Original file line number Diff line number Diff line change
@@ -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분 주기 갱신(캐시 스냅샷) — 출력에 지연 명시
4 changes: 2 additions & 2 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<response><header><resultCode/><resultMsg/></header><body><items><item/>…<totalCount/><pageNo/></body></response>` — 본문 페이지네이션. **`resultCode != "00"`이면 에러**(HTTP 200이라도 봉투로 옴; 게이트웨이 키 차단은 `<header>` 대신 `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분 지연(캐시 스냅샷)" 명시.
Expand Down
4 changes: 2 additions & 2 deletions docs/services.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 16 additions & 16 deletions tests/test_ev_charger_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 # 지역 미지정 → 전국
Expand All @@ -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 전에 막힘
Expand All @@ -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으로 클램프


Expand All @@ -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 전용
Expand All @@ -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 # 미상 코드엔 괄호 라벨 없음

Expand All @@ -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 # 이중 인코딩 힌트

Expand All @@ -168,7 +168,7 @@ async def test_gateway_cmmmsgheader_error(tools, monkeypatch, recording_http):
# 게이트웨이 차단은 <header> 없이 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


Expand All @@ -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


Expand All @@ -201,27 +201,27 @@ 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, "<html><title>403 Forbidden</title></html>"))
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 "<html>" not in out and "<title>" not in out


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
Loading