first commit

This commit is contained in:
2026-02-12 15:29:46 +08:00
commit 7641aabbf4
21 changed files with 5619 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.eggs/
*.egg
.pytest_cache/
.tox/
.venv/
venv/
.env
*.log
+22
View File
@@ -0,0 +1,22 @@
# 更新日志
所有重要变更都会记录在此文件中。
## [1.0.0] - 2026-02-12
### 新增
- 初始版本发布
- 支持通过行政区划代码获取天气数据(省/市/区县三级级联选择)
- 支持通过 Home Assistant 地点经纬度获取天气数据
- 实时天气实体:温度、体感温度、湿度、气压、风速、风向、云量、能见度等
- 7天逐日天气预报(`weather.get_forecasts` daily 服务)
- 24小时逐小时天气预报(`weather.get_forecasts` hourly 服务)
- 气象预警独立传感器实体
- 空气质量指数(AQI)独立传感器实体
- PM2.5、PM10、NO₂、SO₂、O₃、CO 空气质量子指标
- 配置流支持 AK 密钥连通性校验与重复配置拦截
- 选项流支持更新间隔配置(300-7200秒)
- 中文(zh-Hans)与英文(en)国际化支持
- 诊断导出支持(自动脱敏 AK
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 hass_weather_baidu contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+148
View File
@@ -0,0 +1,148 @@
# 百度天气 - Home Assistant 自定义集成
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://github.com/hacs/integration)
[![GitHub Release](https://img.shields.io/github/release/xyzmos/hass_weather_baidu.svg)](https://github.com/xyzmos/hass_weather_baidu/releases)
[![License](https://img.shields.io/github/license/xyzmos/hass_weather_baidu.svg)](LICENSE)
基于百度地图天气服务的 Home Assistant 天气集成插件,支持获取实时天气、逐小时预报、7天预报及气象预警信息。
## ✨ 功能特性
- 🌤️ **实时天气**:温度、体感温度、湿度、风速、风向、云量、能见度、气压等
- 📊 **逐小时预报**:未来24小时逐小时天气预报
- 📅 **7天预报**:未来7天每日天气预报
- ⚠️ **气象预警**:独立传感器实体,实时推送预警信息
- 🌬️ **空气质量**AQI、PM2.5、PM10、NO₂、SO₂、O₃、CO
- 🗣️ **语音助手支持**:所有关键属性均可被语音助手读取
- 🌐 **多语言支持**:支持中文和英文界面
- 🔧 **双配置模式**
- 按行政区划代码选择(省/市/区县级联)
- 按 Home Assistant 地点经纬度选择
## 📋 前置要求
1. 百度地图开放平台账号
2. 天气服务 API KeyAK)— 在[百度地图开放平台 API 控制台](https://lbsyun.baidu.com/apiconsole/key)申请
## 📦 安装方式
### HACS 安装(推荐)
1. 打开 HACS
2. 点击右上角 **⋮** > **自定义存储库**
3. 添加仓库地址:`https://github.com/xyzmos/hass_weather_baidu`
4. 类别选择:**集成**
5. 搜索 **百度天气** 并安装
6. 重启 Home Assistant
### 手动安装
1. 下载最新 [Release](https://github.com/xyzmos/hass_weather_baidu/releases)
2.`custom_components/hass_weather_baidu` 文件夹复制到你的 HA 配置目录下的 `custom_components/` 目录
3. 重启 Home Assistant
## ⚙️ 配置
### 通过 UI 配置
1. 进入 **设置** > **设备与服务** > **添加集成**
2. 搜索 **百度天气**
3. 输入你的百度地图 AK 密钥
4. 选择配置模式:
- **按行政区划选择**:依次选择省份 → 城市 → 区县
- **按地点经纬度选择**:从 Home Assistant 已配置的地点列表中选择
### 选项配置
添加集成后,可在选项中调整:
| 选项 | 默认值 | 说明 |
|------|--------|------|
| 更新间隔 | 900秒(15分钟) | 天气数据更新频率,范围 300-7200 秒 |
## 📊 实体说明
### 天气实体 (weather.*)
| 属性 | 说明 |
|------|------|
| temperature | 当前温度 (°C) |
| apparent_temperature | 体感温度 (°C) |
| humidity | 相对湿度 (%) |
| pressure | 气压 (hPa) |
| wind_speed | 风速 (km/h) |
| wind_bearing | 风向角度 |
| cloud_coverage | 云量 (%) |
| visibility | 能见度 (km) |
| ozone | 臭氧浓度 |
| dew_point | 露点温度 |
| uv_index | 紫外线指数 |
| condition | 天气状态 |
**额外属性(extra_state_attributes):**
| 属性 | 说明 |
|------|------|
| aqi | 空气质量指数 |
| pm25 | PM2.5 浓度 |
| pm10 | PM10 浓度 |
| condition_cn | 中文天气描述 |
| wind_class | 风力等级 |
| wind_direction_cn | 中文风向 |
| precipitation_1h | 1小时累计降水量 |
| update_time | 数据更新时间 |
### 预警传感器 (sensor.*_weather_alert)
| 属性 | 说明 |
|------|------|
| state | 预警数量描述 |
| alert_count | 预警条数 |
| alert_type | 首条预警类型 |
| alert_level | 首条预警等级 |
| alert_title | 首条预警标题 |
| alert_description | 首条预警详情 |
| alerts | 所有预警详情列表 |
### 空气质量传感器 (sensor.*_aqi)
| 属性 | 说明 |
|------|------|
| state | AQI 数值 |
| aqi_level | AQI 等级描述 |
| pm25 | PM2.5 浓度 |
| pm10 | PM10 浓度 |
| no2 | NO₂ 浓度 |
| so2 | SO₂ 浓度 |
| o3 | O₃ 浓度 |
| co | CO 浓度 |
## 🗣️ 语音助手使用
天气实体的所有核心属性(温度、湿度、天气状况等)均可被 Home Assistant 语音助手自动读取。预警与空气质量传感器也可通过语音查询。
示例语音指令:
- "今天天气怎么样?"
- "现在温度多少度?"
- "有没有天气预警?"
- "空气质量怎么样?"
## 🔍 诊断
集成支持 Home Assistant 诊断功能,可在 **设置** > **设备与服务** > **百度天气** > **诊断** 中导出调试信息(AK 密钥会被自动脱敏)。
## 📄 数据来源
天气数据由[百度地图天气服务](https://lbsyun.baidu.com/faq/api?title=webapi/weather/base)提供。
## 📝 许可证
本项目基于 [Apache License 2.0](LICENSE) 许可证开源。
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📌 更新日志
详见 [CHANGELOG.md](CHANGELOG.md)
@@ -0,0 +1,54 @@
"""The Baidu Weather integration."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
from .coordinator import BaiduWeatherCoordinator
_LOGGER = logging.getLogger(__name__)
type BaiduWeatherConfigEntry = ConfigEntry[BaiduWeatherCoordinator]
async def async_setup_entry(
hass: HomeAssistant, entry: BaiduWeatherConfigEntry
) -> bool:
"""Set up Baidu Weather from a config entry."""
coordinator = BaiduWeatherCoordinator(hass, entry)
try:
await coordinator.async_config_entry_first_refresh()
except ConfigEntryAuthFailed:
raise
except Exception as err:
raise ConfigEntryNotReady(
f"初始化百度天气数据失败: {err}"
) from err
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def async_unload_entry(
hass: HomeAssistant, entry: BaiduWeatherConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def _async_update_listener(
hass: HomeAssistant, entry: BaiduWeatherConfigEntry
) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
+187
View File
@@ -0,0 +1,187 @@
"""API client for Baidu Weather service."""
from __future__ import annotations
import asyncio
import csv
import io
import logging
import os
from typing import Any
import aiohttp
from .const import (
ABNORMAL_INT,
ABNORMAL_STR,
BAIDU_WEATHER_API,
DATA_TYPE_ALL,
)
_LOGGER = logging.getLogger(__name__)
class BaiduWeatherApiError(Exception):
"""Base exception for Baidu Weather API errors."""
class BaiduWeatherAuthError(BaiduWeatherApiError):
"""Authentication error."""
class BaiduWeatherConnectionError(BaiduWeatherApiError):
"""Connection error."""
class BaiduWeatherApiClient:
"""Client for the Baidu Weather API."""
def __init__(
self,
session: aiohttp.ClientSession,
ak: str,
) -> None:
"""Initialize the API client."""
self._session = session
self._ak = ak
async def async_get_weather_by_district(
self, district_id: str, data_type: str = DATA_TYPE_ALL
) -> dict[str, Any]:
"""Get weather data by district ID."""
params = {
"district_id": district_id,
"data_type": data_type,
"ak": self._ak,
"output": "json",
}
return await self._async_request(params)
async def async_get_weather_by_location(
self,
longitude: float,
latitude: float,
data_type: str = DATA_TYPE_ALL,
coordtype: str = "wgs84",
) -> dict[str, Any]:
"""Get weather data by longitude and latitude.
Note: Baidu API expects location format as "longitude,latitude"
(经度在前,纬度在后).
"""
params = {
"location": f"{longitude},{latitude}",
"data_type": data_type,
"ak": self._ak,
"output": "json",
"coordtype": coordtype,
}
return await self._async_request(params)
async def async_validate_ak(self) -> bool:
"""Validate the API key by making a test request."""
try:
params = {
"district_id": "110100", # 北京市
"data_type": "now",
"ak": self._ak,
"output": "json",
}
await self._async_request(params)
return True
except BaiduWeatherAuthError:
return False
except BaiduWeatherApiError:
raise
async def _async_request(self, params: dict[str, Any]) -> dict[str, Any]:
"""Make API request and handle response."""
try:
async with asyncio.timeout(30):
response = await self._session.get(
BAIDU_WEATHER_API, params=params
)
response.raise_for_status()
data = await response.json(content_type=None)
except asyncio.TimeoutError as err:
raise BaiduWeatherConnectionError(
"请求百度天气API超时"
) from err
except aiohttp.ClientError as err:
raise BaiduWeatherConnectionError(
f"连接百度天气API失败: {err}"
) from err
status = data.get("status")
if status != 0:
message = data.get("message", "未知错误")
if status in (1, 2, 3, 4, 5, 200, 201, 202, 211, 220, 240):
# Auth related errors
raise BaiduWeatherAuthError(
f"百度天气API认证失败 (状态码: {status}): {message}"
)
raise BaiduWeatherApiError(
f"百度天气API请求失败 (状态码: {status}): {message}"
)
result = data.get("result", {})
return self._clean_data(result)
def _clean_data(self, data: Any) -> Any:
"""Clean abnormal values from API response."""
if isinstance(data, dict):
return {k: self._clean_data(v) for k, v in data.items()}
if isinstance(data, list):
return [self._clean_data(item) for item in data]
if data == ABNORMAL_INT:
return None
if data == ABNORMAL_STR:
return None
return data
def load_district_data_from_csv(
csv_path: str | None = None,
) -> dict[str, dict[str, dict[str, str]]]:
"""Load and parse the district ID CSV data from local file.
CSV format: district_id,province,city,city_geocode,district,district_geocode,lon,lat
Returns a nested dict: {province: {city: {district: district_id}}}
"""
if csv_path is None:
# Default: look for CSV in the integration directory
csv_path = os.path.join(
os.path.dirname(__file__), "weather_district_id.csv"
)
districts: dict[str, dict[str, dict[str, str]]] = {}
try:
with open(csv_path, encoding="utf-8") as f:
reader = csv.reader(f)
# Skip header
next(reader, None)
for row in reader:
if len(row) < 5:
continue
# CSV columns: 0=district_id, 1=province, 2=city,
# 3=city_geocode, 4=district, 5=district_geocode,
# 6=lon, 7=lat
district_id = row[0].strip()
province = row[1].strip()
city = row[2].strip()
district = row[4].strip()
if not district_id or not province or not district:
continue
districts.setdefault(province, {}).setdefault(city, {})[
district
] = district_id
except FileNotFoundError:
_LOGGER.error("行政区划数据文件未找到: %s", csv_path)
except Exception as err:
_LOGGER.error("解析行政区划数据文件失败: %s", err)
return districts
@@ -0,0 +1,359 @@
"""Config flow for Baidu Weather integration."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import (
BaiduWeatherApiClient,
BaiduWeatherApiError,
BaiduWeatherAuthError,
BaiduWeatherConnectionError,
load_district_data_from_csv,
)
from .const import (
CONF_AK,
CONF_CITY,
CONF_DISTRICT,
CONF_DISTRICT_ID,
CONF_LATITUDE,
CONF_LOCATION_NAME,
CONF_LONGITUDE,
CONF_MODE,
CONF_PROVINCE,
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
MODE_DISTRICT,
MODE_LOCATION,
)
_LOGGER = logging.getLogger(__name__)
class BaiduWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Baidu Weather."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._ak: str = ""
self._mode: str = ""
self._districts: dict[str, dict[str, dict[str, str]]] = {}
self._province: str = ""
self._city: str = ""
self._district: str = ""
self._district_id: str = ""
self._location_name: str = ""
self._latitude: float = 0.0
self._longitude: float = 0.0
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - enter AK and select mode."""
errors: dict[str, str] = {}
if user_input is not None:
self._ak = user_input[CONF_AK]
self._mode = user_input[CONF_MODE]
# Validate AK
session = async_get_clientsession(self.hass)
client = BaiduWeatherApiClient(session=session, ak=self._ak)
try:
valid = await client.async_validate_ak()
if not valid:
errors["base"] = "invalid_ak"
except BaiduWeatherConnectionError:
errors["base"] = "cannot_connect"
except BaiduWeatherApiError:
errors["base"] = "unknown"
if not errors:
if self._mode == MODE_DISTRICT:
return await self.async_step_province()
return await self.async_step_location()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_AK): str,
vol.Required(CONF_MODE, default=MODE_DISTRICT): vol.In(
{
MODE_DISTRICT: "按行政区划选择",
MODE_LOCATION: "按地点经纬度选择",
}
),
}
),
errors=errors,
)
async def async_step_province(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle province selection step."""
errors: dict[str, str] = {}
if not self._districts:
# Load from local CSV file
self._districts = await self.hass.async_add_executor_job(
load_district_data_from_csv
)
if not self._districts:
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="province",
data_schema=vol.Schema(
{vol.Required(CONF_PROVINCE): str}
),
errors=errors,
)
if user_input is not None:
self._province = user_input[CONF_PROVINCE]
return await self.async_step_city()
provinces = sorted(self._districts.keys())
return self.async_show_form(
step_id="province",
data_schema=vol.Schema(
{
vol.Required(CONF_PROVINCE): vol.In(
{p: p for p in provinces}
),
}
),
errors=errors,
)
async def async_step_city(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle city selection step."""
if user_input is not None:
self._city = user_input[CONF_CITY]
return await self.async_step_district()
cities = sorted(self._districts.get(self._province, {}).keys())
return self.async_show_form(
step_id="city",
data_schema=vol.Schema(
{
vol.Required(CONF_CITY): vol.In({c: c for c in cities}),
}
),
)
async def async_step_district(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle district selection step."""
if user_input is not None:
self._district = user_input[CONF_DISTRICT]
self._district_id = (
self._districts.get(self._province, {})
.get(self._city, {})
.get(self._district, "")
)
if not self._district_id:
return self.async_show_form(
step_id="district",
data_schema=vol.Schema(
{vol.Required(CONF_DISTRICT): str}
),
errors={"base": "unknown"},
)
# Check for duplicate
await self.async_set_unique_id(
f"{DOMAIN}_{self._district_id}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"{self._province} {self._city} {self._district}",
data={
CONF_AK: self._ak,
CONF_MODE: MODE_DISTRICT,
CONF_DISTRICT_ID: self._district_id,
CONF_PROVINCE: self._province,
CONF_CITY: self._city,
CONF_DISTRICT: self._district,
CONF_LOCATION_NAME: f"{self._city}{self._district}",
},
)
districts_in_city = (
self._districts.get(self._province, {}).get(self._city, {})
)
district_names = sorted(districts_in_city.keys())
return self.async_show_form(
step_id="district",
data_schema=vol.Schema(
{
vol.Required(CONF_DISTRICT): vol.In(
{d: d for d in district_names}
),
}
),
)
async def async_step_location(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle location selection step using HA zones."""
errors: dict[str, str] = {}
if user_input is not None:
selected = user_input.get("zone")
if selected == "__home__":
self._location_name = "Home"
self._latitude = self.hass.config.latitude
self._longitude = self.hass.config.longitude
elif selected == "__manual__":
# Manual input mode
lat = user_input.get(CONF_LATITUDE)
lon = user_input.get(CONF_LONGITUDE)
name = user_input.get(CONF_LOCATION_NAME, "自定义位置")
if lat is not None and lon is not None:
self._location_name = name
self._latitude = float(lat)
self._longitude = float(lon)
else:
errors["base"] = "invalid_location"
else:
zone = self.hass.states.get(selected)
if zone:
self._location_name = zone.name
self._latitude = zone.attributes.get("latitude", 0.0)
self._longitude = zone.attributes.get("longitude", 0.0)
else:
errors["base"] = "invalid_location"
if not errors:
if self._latitude == 0.0 and self._longitude == 0.0:
errors["base"] = "invalid_location"
if not errors:
# Validate we can get weather for this location
session = async_get_clientsession(self.hass)
client = BaiduWeatherApiClient(session=session, ak=self._ak)
try:
await client.async_get_weather_by_location(
longitude=self._longitude,
latitude=self._latitude,
data_type="now",
)
except BaiduWeatherAuthError:
errors["base"] = "invalid_ak"
except BaiduWeatherConnectionError:
errors["base"] = "cannot_connect"
except BaiduWeatherApiError:
errors["base"] = "unknown"
if not errors:
# Check for duplicate
unique = f"{DOMAIN}_{self._latitude}_{self._longitude}"
await self.async_set_unique_id(unique)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"百度天气 - {self._location_name}",
data={
CONF_AK: self._ak,
CONF_MODE: MODE_LOCATION,
CONF_LATITUDE: self._latitude,
CONF_LONGITUDE: self._longitude,
CONF_LOCATION_NAME: self._location_name,
},
)
# Build zone options - always include Home and manual entry
zone_options: dict[str, str] = {
"__home__": f"Home ({self.hass.config.latitude}, {self.hass.config.longitude})"
}
# Add configured zones
for state in self.hass.states.async_all("zone"):
lat = state.attributes.get("latitude", "")
lon = state.attributes.get("longitude", "")
zone_options[state.entity_id] = f"{state.name} ({lat}, {lon})"
# Add manual option
zone_options["__manual__"] = "手动输入经纬度"
schema_fields: dict[Any, Any] = {
vol.Required("zone", default="__home__"): vol.In(zone_options),
}
# Add manual input fields (always shown, only used when __manual__ selected)
schema_fields[vol.Optional(CONF_LOCATION_NAME)] = str
schema_fields[vol.Optional(CONF_LATITUDE)] = vol.Coerce(float)
schema_fields[vol.Optional(CONF_LONGITUDE)] = vol.Coerce(float)
return self.async_show_form(
step_id="location",
data_schema=vol.Schema(schema_fields),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(
config_entry: ConfigEntry,
) -> BaiduWeatherOptionsFlowHandler:
"""Get the options flow for this handler."""
return BaiduWeatherOptionsFlowHandler()
class BaiduWeatherOptionsFlowHandler(OptionsFlow):
"""Handle options flow for Baidu Weather."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
return self.async_create_entry(
title="",
data={
CONF_UPDATE_INTERVAL: user_input[CONF_UPDATE_INTERVAL],
},
)
current_interval = self.config_entry.options.get(
CONF_UPDATE_INTERVAL,
int(DEFAULT_UPDATE_INTERVAL.total_seconds()),
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_UPDATE_INTERVAL,
default=current_interval,
): vol.All(
vol.Coerce(int),
vol.Range(min=300, max=7200),
),
}
),
)
@@ -0,0 +1,131 @@
"""Constants for the Baidu Weather integration."""
from datetime import timedelta
DOMAIN = "hass_weather_baidu"
# Configuration keys
CONF_AK = "ak"
CONF_MODE = "mode"
CONF_DISTRICT_ID = "district_id"
CONF_LOCATION_NAME = "location_name"
CONF_LATITUDE = "latitude"
CONF_LONGITUDE = "longitude"
CONF_UPDATE_INTERVAL = "update_interval"
CONF_PROVINCE = "province"
CONF_CITY = "city"
CONF_DISTRICT = "district"
# Mode values
MODE_DISTRICT = "district"
MODE_LOCATION = "location"
# API
BAIDU_WEATHER_API = "https://api.map.baidu.com/weather/v1/"
# Default values
DEFAULT_UPDATE_INTERVAL = timedelta(minutes=15)
DEFAULT_NAME = "百度天气"
# Data types for API
DATA_TYPE_ALL = "all"
DATA_TYPE_NOW = "now"
DATA_TYPE_FORECAST = "fc"
DATA_TYPE_FORECAST_HOUR = "fc_hour"
DATA_TYPE_ALERT = "alert"
DATA_TYPE_INDEX = "index"
# Coordinator data keys
KEY_NOW = "now"
KEY_FORECASTS = "forecasts"
KEY_FORECAST_HOURS = "forecast_hours"
KEY_ALERTS = "alerts"
KEY_INDEXES = "indexes"
KEY_LOCATION = "location"
# Platforms
PLATFORMS = ["weather", "sensor"]
# Abnormal values from API
ABNORMAL_INT = 999999
ABNORMAL_STR = "暂无"
# Baidu weather condition to HA condition mapping
CONDITION_MAP: dict[str, str] = {
"": "sunny",
"多云": "partlycloudy",
"": "cloudy",
"阵雨": "rainy",
"雷阵雨": "lightning-rainy",
"雷阵雨伴有冰雹": "hail",
"雨夹雪": "snowy-rainy",
"小雨": "rainy",
"中雨": "rainy",
"大雨": "pouring",
"暴雨": "pouring",
"大暴雨": "pouring",
"特大暴雨": "pouring",
"阵雪": "snowy",
"小雪": "snowy",
"中雪": "snowy",
"大雪": "snowy",
"暴雪": "snowy",
"": "fog",
"冻雨": "snowy-rainy",
"沙尘暴": "exceptional",
"小到中雨": "rainy",
"中到大雨": "rainy",
"大到暴雨": "pouring",
"暴雨到大暴雨": "pouring",
"大暴雨到特大暴雨": "pouring",
"小到中雪": "snowy",
"中到大雪": "snowy",
"大到暴雪": "snowy",
"浮尘": "exceptional",
"扬沙": "exceptional",
"强沙尘暴": "exceptional",
"": "fog",
"小雨-中雨": "rainy",
"中雨-大雨": "rainy",
"大雨-暴雨": "pouring",
"暴雨-大暴雨": "pouring",
"大暴雨-特大暴雨": "pouring",
"小雪-中雪": "snowy",
"中雪-大雪": "snowy",
"大雪-暴雪": "snowy",
}
# Wind class to speed mapping (km/h, approximate midpoint)
WIND_SPEED_MAP: dict[str, float] = {
"微风": 5.0,
"和风": 15.0,
"清风": 25.0,
"<3级": 9.0,
"1级": 2.0,
"2级": 7.0,
"3级": 14.0,
"4级": 22.0,
"5级": 32.0,
"6级": 42.0,
"7级": 54.0,
"8级": 67.0,
"9级": 81.0,
"10级": 96.0,
"11级": 112.0,
"12级": 130.0,
}
# Wind direction to bearing mapping
WIND_BEARING_MAP: dict[str, float] = {
"北风": 0.0,
"东北风": 45.0,
"东风": 90.0,
"东南风": 135.0,
"南风": 180.0,
"西南风": 225.0,
"西风": 270.0,
"西北风": 315.0,
}
# Attribution
ATTRIBUTION = "数据来源:百度地图天气服务"
@@ -0,0 +1,117 @@
"""DataUpdateCoordinator for Baidu Weather integration."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import (
DataUpdateCoordinator,
UpdateFailed,
)
from .api import (
BaiduWeatherApiClient,
BaiduWeatherApiError,
BaiduWeatherAuthError,
BaiduWeatherConnectionError,
)
from .const import (
CONF_AK,
CONF_DISTRICT_ID,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_MODE,
CONF_UPDATE_INTERVAL,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
KEY_ALERTS,
KEY_FORECAST_HOURS,
KEY_FORECASTS,
KEY_INDEXES,
KEY_LOCATION,
KEY_NOW,
MODE_DISTRICT,
)
_LOGGER = logging.getLogger(__name__)
type BaiduWeatherConfigEntry = ConfigEntry[BaiduWeatherCoordinator]
class BaiduWeatherCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for fetching Baidu Weather data."""
config_entry: BaiduWeatherConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: BaiduWeatherConfigEntry,
) -> None:
"""Initialize the coordinator."""
self.api = BaiduWeatherApiClient(
session=async_get_clientsession(hass),
ak=config_entry.data[CONF_AK],
)
self._mode = config_entry.data[CONF_MODE]
self._district_id: str | None = config_entry.data.get(CONF_DISTRICT_ID)
self._latitude: float | None = config_entry.data.get(CONF_LATITUDE)
self._longitude: float | None = config_entry.data.get(CONF_LONGITUDE)
update_interval = config_entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL.total_seconds()
)
from datetime import timedelta
if isinstance(update_interval, (int, float)):
update_interval = timedelta(seconds=update_interval)
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN}_{config_entry.entry_id}",
update_interval=update_interval,
config_entry=config_entry,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from Baidu Weather API."""
try:
if self._mode == MODE_DISTRICT and self._district_id:
raw = await self.api.async_get_weather_by_district(
self._district_id
)
elif self._latitude is not None and self._longitude is not None:
raw = await self.api.async_get_weather_by_location(
longitude=self._longitude,
latitude=self._latitude,
)
else:
raise UpdateFailed("未配置有效的位置信息")
except BaiduWeatherAuthError as err:
raise UpdateFailed(f"API 认证失败: {err}") from err
except BaiduWeatherConnectionError as err:
raise UpdateFailed(f"连接失败: {err}") from err
except BaiduWeatherApiError as err:
raise UpdateFailed(f"API 错误: {err}") from err
# Structure the returned data
location_info = raw.get("location", {})
now_data = raw.get("now", {})
forecasts = raw.get("forecasts", [])
forecast_hours = raw.get("forecast_hours", [])
alerts = raw.get("alerts", [])
indexes = raw.get("indexes", [])
return {
KEY_LOCATION: location_info,
KEY_NOW: now_data,
KEY_FORECASTS: forecasts,
KEY_FORECAST_HOURS: forecast_hours,
KEY_ALERTS: alerts,
KEY_INDEXES: indexes,
}
@@ -0,0 +1,26 @@
"""Diagnostics support for Baidu Weather integration."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import CONF_AK
TO_REDACT = {CONF_AK}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry_data": async_redact_data(dict(entry.data), TO_REDACT),
"config_entry_options": dict(entry.options),
"coordinator_data": coordinator.data if coordinator.data else {},
}
@@ -0,0 +1,12 @@
{
"domain": "hass_weather_baidu",
"name": "百度天气",
"codeowners": ["@your-github-username"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-github-username/hass_weather_baidu",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/your-github-username/hass_weather_baidu/issues",
"requirements": ["aiohttp>=3.8.0"],
"version": "1.0.0"
}
@@ -0,0 +1,189 @@
"""Sensor entities for Baidu Weather integration - Weather Alerts."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTRIBUTION,
CONF_LOCATION_NAME,
DOMAIN,
KEY_ALERTS,
KEY_INDEXES,
)
from .coordinator import BaiduWeatherCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Baidu Weather sensor entities from a config entry."""
coordinator: BaiduWeatherCoordinator = entry.runtime_data
location_name = entry.data.get(CONF_LOCATION_NAME, "百度天气")
entities: list[SensorEntity] = [
BaiduWeatherAlertSensor(coordinator, entry, location_name),
BaiduWeatherAqiSensor(coordinator, entry, location_name),
]
async_add_entities(entities)
class BaiduWeatherAlertSensor(
CoordinatorEntity[BaiduWeatherCoordinator], SensorEntity
):
"""Sensor entity for weather alerts."""
_attr_has_entity_name = True
_attr_translation_key = "weather_alert"
_attr_icon = "mdi:alert-circle-outline"
_attr_attribution = ATTRIBUTION
def __init__(
self,
coordinator: BaiduWeatherCoordinator,
entry: ConfigEntry,
location_name: str,
) -> None:
"""Initialize the alert sensor."""
super().__init__(coordinator)
self._entry = entry
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_alert"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="百度地图",
name=f"百度天气 - {location_name}",
configuration_url="https://lbsyun.baidu.com/",
)
@property
def native_value(self) -> str | None:
"""Return the number of active alerts or 'None'."""
if self.coordinator.data is None:
return None
alerts = self.coordinator.data.get(KEY_ALERTS, [])
if not alerts:
return "无预警"
return f"{len(alerts)}条预警"
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return detailed alert information."""
attrs: dict[str, Any] = {}
if self.coordinator.data is None:
return attrs
alerts = self.coordinator.data.get(KEY_ALERTS, [])
attrs["alert_count"] = len(alerts)
attrs["alerts"] = []
for i, alert in enumerate(alerts):
alert_info = {
"type": alert.get("type", "未知"),
"level": alert.get("level", "未知"),
"title": alert.get("title", ""),
"description": alert.get("desc", ""),
}
attrs["alerts"].append(alert_info)
# Also expose top-level attributes for voice assistant readability
if i == 0:
attrs["alert_type"] = alert.get("type", "未知")
attrs["alert_level"] = alert.get("level", "未知")
attrs["alert_title"] = alert.get("title", "")
attrs["alert_description"] = alert.get("desc", "")
return attrs
class BaiduWeatherAqiSensor(
CoordinatorEntity[BaiduWeatherCoordinator], SensorEntity
):
"""Sensor entity for Air Quality Index."""
_attr_has_entity_name = True
_attr_translation_key = "aqi"
_attr_icon = "mdi:air-filter"
_attr_attribution = ATTRIBUTION
_attr_state_class = "measurement"
def __init__(
self,
coordinator: BaiduWeatherCoordinator,
entry: ConfigEntry,
location_name: str,
) -> None:
"""Initialize the AQI sensor."""
super().__init__(coordinator)
self._entry = entry
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_aqi"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="百度地图",
name=f"百度天气 - {location_name}",
configuration_url="https://lbsyun.baidu.com/",
)
@property
def native_value(self) -> int | None:
"""Return the AQI value."""
if self.coordinator.data is None:
return None
now = self.coordinator.data.get("now", {})
return now.get("aqi")
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return detailed air quality data."""
attrs: dict[str, Any] = {}
if self.coordinator.data is None:
return attrs
now = self.coordinator.data.get("now", {})
if now.get("pm25") is not None:
attrs["pm25"] = now["pm25"]
if now.get("pm10") is not None:
attrs["pm10"] = now["pm10"]
if now.get("no2") is not None:
attrs["no2"] = now["no2"]
if now.get("so2") is not None:
attrs["so2"] = now["so2"]
if now.get("o3") is not None:
attrs["o3"] = now["o3"]
if now.get("co") is not None:
attrs["co"] = now["co"]
# AQI level description
aqi = now.get("aqi")
if aqi is not None:
if aqi <= 50:
attrs["aqi_level"] = ""
elif aqi <= 100:
attrs["aqi_level"] = ""
elif aqi <= 150:
attrs["aqi_level"] = "轻度污染"
elif aqi <= 200:
attrs["aqi_level"] = "中度污染"
elif aqi <= 300:
attrs["aqi_level"] = "重度污染"
else:
attrs["aqi_level"] = "严重污染"
return attrs
@@ -0,0 +1,75 @@
{
"config": {
"step": {
"user": {
"title": "百度天气配置",
"description": "请输入百度地图开放平台的 AK 密钥并选择配置模式",
"data": {
"ak": "百度地图 AK 密钥",
"mode": "配置模式"
},
"data_description": {
"ak": "在百度地图开放平台 API 控制台申请获得的开发者密钥",
"mode": "选择通过行政区划或地点经纬度来获取天气数据"
}
},
"province": {
"title": "选择省份",
"data": {
"province": "省份"
}
},
"city": {
"title": "选择城市",
"data": {
"city": "城市"
}
},
"district": {
"title": "选择区县",
"data": {
"district": "区县"
}
},
"location": {
"title": "选择地点",
"description": "选择 Home Assistant 中已配置的地点",
"data": {
"zone": "地点"
}
}
},
"error": {
"invalid_ak": "无效的 AK 密钥,请检查后重试",
"cannot_connect": "无法连接到百度天气服务,请检查网络",
"unknown": "发生未知错误",
"invalid_location": "无效的地点选择"
},
"abort": {
"already_configured": "该地点已配置"
}
},
"options": {
"step": {
"init": {
"title": "百度天气选项",
"data": {
"update_interval": "更新间隔(秒)"
},
"data_description": {
"update_interval": "天气数据更新间隔,范围 300-7200 秒,默认 900 秒(15分钟)"
}
}
}
},
"entity": {
"sensor": {
"weather_alert": {
"name": "天气预警"
},
"aqi": {
"name": "空气质量指数"
}
}
}
}
@@ -0,0 +1,75 @@
{
"config": {
"step": {
"user": {
"title": "Baidu Weather Configuration",
"description": "Enter your Baidu Map API Key and select configuration mode",
"data": {
"ak": "Baidu Map API Key",
"mode": "Configuration Mode"
},
"data_description": {
"ak": "Developer key obtained from Baidu Map Open Platform API Console",
"mode": "Choose to get weather data by administrative district or location coordinates"
}
},
"province": {
"title": "Select Province",
"data": {
"province": "Province"
}
},
"city": {
"title": "Select City",
"data": {
"city": "City"
}
},
"district": {
"title": "Select District",
"data": {
"district": "District"
}
},
"location": {
"title": "Select Location",
"description": "Select a location configured in Home Assistant",
"data": {
"zone": "Location"
}
}
},
"error": {
"invalid_ak": "Invalid API Key, please check and try again",
"cannot_connect": "Cannot connect to Baidu Weather service, please check your network",
"unknown": "An unknown error occurred",
"invalid_location": "Invalid location selection"
},
"abort": {
"already_configured": "This location is already configured"
}
},
"options": {
"step": {
"init": {
"title": "Baidu Weather Options",
"data": {
"update_interval": "Update Interval (seconds)"
},
"data_description": {
"update_interval": "Weather data update interval, range 300-7200 seconds, default 900 seconds (15 minutes)"
}
}
}
},
"entity": {
"sensor": {
"weather_alert": {
"name": "Weather Alert"
},
"aqi": {
"name": "Air Quality Index"
}
}
}
}
@@ -0,0 +1,75 @@
{
"config": {
"step": {
"user": {
"title": "百度天气配置",
"description": "请输入百度地图开放平台的 AK 密钥并选择配置模式",
"data": {
"ak": "百度地图 AK 密钥",
"mode": "配置模式"
},
"data_description": {
"ak": "在百度地图开放平台 API 控制台申请获得的开发者密钥",
"mode": "选择通过行政区划或地点经纬度来获取天气数据"
}
},
"province": {
"title": "选择省份",
"data": {
"province": "省份"
}
},
"city": {
"title": "选择城市",
"data": {
"city": "城市"
}
},
"district": {
"title": "选择区县",
"data": {
"district": "区县"
}
},
"location": {
"title": "选择地点",
"description": "选择 Home Assistant 中已配置的地点",
"data": {
"zone": "地点"
}
}
},
"error": {
"invalid_ak": "无效的 AK 密钥,请检查后重试",
"cannot_connect": "无法连接到百度天气服务,请检查网络",
"unknown": "发生未知错误",
"invalid_location": "无效的地点选择"
},
"abort": {
"already_configured": "该地点已配置"
}
},
"options": {
"step": {
"init": {
"title": "百度天气选项",
"data": {
"update_interval": "更新间隔(秒)"
},
"data_description": {
"update_interval": "天气数据更新间隔,范围 300-7200 秒,默认 900 秒(15分钟)"
}
}
}
},
"entity": {
"sensor": {
"weather_alert": {
"name": "天气预警"
},
"aqi": {
"name": "空气质量指数"
}
}
}
}
@@ -0,0 +1,318 @@
"""Weather entity for Baidu Weather integration."""
from __future__ import annotations
from datetime import datetime
import logging
from typing import Any
from homeassistant.components.weather import (
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTRIBUTION,
CONDITION_MAP,
CONF_LOCATION_NAME,
DOMAIN,
KEY_FORECAST_HOURS,
KEY_FORECASTS,
KEY_NOW,
WIND_BEARING_MAP,
WIND_SPEED_MAP,
)
from .coordinator import BaiduWeatherCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Baidu Weather entity from a config entry."""
coordinator: BaiduWeatherCoordinator = entry.runtime_data
async_add_entities([BaiduWeatherEntity(coordinator, entry)])
class BaiduWeatherEntity(CoordinatorEntity[BaiduWeatherCoordinator], WeatherEntity):
"""Representation of a Baidu Weather entity."""
_attr_has_entity_name = True
_attr_name = None
_attr_attribution = ATTRIBUTION
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY
| WeatherEntityFeature.FORECAST_HOURLY
)
def __init__(
self,
coordinator: BaiduWeatherCoordinator,
entry: ConfigEntry,
) -> None:
"""Initialize the weather entity."""
super().__init__(coordinator)
self._entry = entry
location_name = entry.data.get(CONF_LOCATION_NAME, "百度天气")
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, entry.entry_id)},
manufacturer="百度地图",
name=f"百度天气 - {location_name}",
configuration_url="https://lbsyun.baidu.com/",
)
@property
def _now_data(self) -> dict[str, Any]:
"""Return current weather data."""
if self.coordinator.data is None:
return {}
return self.coordinator.data.get(KEY_NOW, {})
@property
def condition(self) -> str | None:
"""Return the current condition."""
text = self._now_data.get("text")
if text is None:
return None
return CONDITION_MAP.get(text, "exceptional")
@property
def native_temperature(self) -> float | None:
"""Return the temperature."""
return self._now_data.get("temp")
@property
def native_apparent_temperature(self) -> float | None:
"""Return the apparent temperature."""
return self._now_data.get("feels_like")
@property
def humidity(self) -> float | None:
"""Return the humidity."""
return self._now_data.get("rh")
@property
def native_pressure(self) -> float | None:
"""Return the pressure."""
return self._now_data.get("pressure")
@property
def native_wind_speed(self) -> float | None:
"""Return the wind speed."""
wind_class = self._now_data.get("wind_class")
if wind_class is None:
return None
return WIND_SPEED_MAP.get(wind_class)
@property
def wind_bearing(self) -> float | str | None:
"""Return the wind bearing."""
wind_dir = self._now_data.get("wind_dir")
if wind_dir is None:
return None
# Try numeric angle first
wind_angle = self._now_data.get("wind_angle")
if wind_angle is not None:
return float(wind_angle)
return WIND_BEARING_MAP.get(wind_dir, wind_dir)
@property
def cloud_coverage(self) -> float | None:
"""Return the cloud coverage."""
return self._now_data.get("clouds")
@property
def visibility(self) -> float | None:
"""Return the visibility in km."""
vis = self._now_data.get("vis")
if vis is None:
return None
# API returns meters, convert to km
return vis / 1000.0
@property
def native_visibility(self) -> float | None:
"""Return visibility in km."""
vis = self._now_data.get("vis")
if vis is None:
return None
return vis / 1000.0
@property
def native_visibility_unit(self) -> str:
"""Return the visibility unit."""
from homeassistant.const import UnitOfLength
return UnitOfLength.KILOMETERS
@property
def ozone(self) -> float | None:
"""Return the ozone level."""
return self._now_data.get("o3")
@property
def native_dew_point(self) -> float | None:
"""Return the dew point."""
return self._now_data.get("dpt")
@property
def uv_index(self) -> float | None:
"""Return the UV index."""
return self._now_data.get("uvi")
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes for voice assistants."""
attrs: dict[str, Any] = {}
now = self._now_data
if now.get("aqi") is not None:
attrs["aqi"] = now["aqi"]
if now.get("pm25") is not None:
attrs["pm25"] = now["pm25"]
if now.get("pm10") is not None:
attrs["pm10"] = now["pm10"]
if now.get("no2") is not None:
attrs["no2"] = now["no2"]
if now.get("so2") is not None:
attrs["so2"] = now["so2"]
if now.get("co") is not None:
attrs["co"] = now["co"]
if now.get("prec_1h") is not None:
attrs["precipitation_1h"] = now["prec_1h"]
if now.get("text") is not None:
attrs["condition_cn"] = now["text"]
if now.get("wind_class") is not None:
attrs["wind_class"] = now["wind_class"]
if now.get("wind_dir") is not None:
attrs["wind_direction_cn"] = now["wind_dir"]
if now.get("uptime") is not None:
attrs["update_time"] = now["uptime"]
return attrs
async def async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast."""
if self.coordinator.data is None:
return None
forecasts_raw = self.coordinator.data.get(KEY_FORECASTS, [])
if not forecasts_raw:
return None
forecasts: list[Forecast] = []
for fc in forecasts_raw:
date_str = fc.get("date")
if not date_str:
continue
# Parse date to RFC3339 format
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
dt_str = dt.strftime("%Y-%m-%dT00:00:00+08:00")
except (ValueError, TypeError):
dt_str = date_str
condition_day = fc.get("text_day")
condition = CONDITION_MAP.get(condition_day, "exceptional") if condition_day else None
wind_speed = None
wc_day = fc.get("wc_day")
if wc_day:
wind_speed = WIND_SPEED_MAP.get(wc_day)
wind_bearing = None
wd_day = fc.get("wd_day")
if wd_day:
wind_bearing = WIND_BEARING_MAP.get(wd_day)
forecast = Forecast(
datetime=dt_str,
condition=condition,
native_temperature=fc.get("high"),
native_templow=fc.get("low"),
native_wind_speed=wind_speed,
wind_bearing=wind_bearing,
)
forecasts.append(forecast)
return forecasts
async def async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast."""
if self.coordinator.data is None:
return None
hours_raw = self.coordinator.data.get(KEY_FORECAST_HOURS, [])
if not hours_raw:
return None
forecasts: list[Forecast] = []
for hour in hours_raw:
data_time = hour.get("data_time")
if not data_time:
continue
# Parse time to RFC3339
try:
# data_time format is like "2024-01-01 12:00"
dt = datetime.strptime(data_time, "%Y-%m-%d %H:%M")
dt_str = dt.strftime("%Y-%m-%dT%H:%M:%S+08:00")
except (ValueError, TypeError):
dt_str = data_time
text = hour.get("text")
condition = CONDITION_MAP.get(text, "exceptional") if text else None
wind_speed = None
wc = hour.get("wind_class")
if wc:
wind_speed = WIND_SPEED_MAP.get(wc)
wind_bearing = None
wd = hour.get("wind_dir")
if wd:
wind_angle = hour.get("wind_angle")
if wind_angle is not None:
wind_bearing = float(wind_angle)
else:
wind_bearing = WIND_BEARING_MAP.get(wd)
forecast = Forecast(
datetime=dt_str,
condition=condition,
native_temperature=hour.get("temp_fc"),
humidity=hour.get("rh"),
cloud_coverage=hour.get("clouds"),
native_precipitation=hour.get("prec_1h"),
native_wind_speed=wind_speed,
wind_bearing=wind_bearing,
precipitation_probability=hour.get("pop"),
uv_index=hour.get("uvi"),
native_pressure=hour.get("pressure"),
native_dew_point=hour.get("dpt"),
)
forecasts.append(forecast)
return forecasts
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"name": "百度天气",
"render_readme": true,
"domains": ["weather", "sensor"],
"country": ["CN"],
"homeassistant": "2024.1.0",
"iot_class": "cloud_polling"
}
+1
View File
@@ -0,0 +1 @@
"""Tests for the Baidu Weather integration."""
+114
View File
@@ -0,0 +1,114 @@
"""Fixtures and mock data for Baidu Weather tests."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
MOCK_AK = "test_ak_key_123456"
MOCK_WEATHER_RESPONSE = {
"status": 0,
"result": {
"location": {
"country": "中国",
"province": "北京市",
"city": "北京市",
"name": "海淀区",
"id": "110108",
},
"now": {
"temp": 25,
"feels_like": 27,
"rh": 60,
"wind_class": "3级",
"wind_dir": "南风",
"text": "",
"prec_1h": 0.0,
"clouds": 20,
"vis": 10000,
"aqi": 75,
"pm25": 35,
"pm10": 50,
"no2": 20,
"so2": 5,
"o3": 100,
"co": 0.5,
"uptime": "2026-02-12 14:00",
},
"forecasts": [
{
"date": "2026-02-12",
"week": "星期四",
"high": 28,
"low": 18,
"wc_day": "3级",
"wc_night": "2级",
"wd_day": "南风",
"wd_night": "北风",
"text_day": "",
"text_night": "多云",
},
{
"date": "2026-02-13",
"week": "星期五",
"high": 26,
"low": 16,
"wc_day": "4级",
"wc_night": "3级",
"wd_day": "东南风",
"wd_night": "东风",
"text_day": "多云",
"text_night": "小雨",
},
],
"forecast_hours": [
{
"text": "",
"temp_fc": 25,
"wind_class": "3级",
"wind_dir": "南风",
"rh": 60,
"prec_1h": 0.0,
"clouds": 20,
"data_time": "2026-02-12 15:00",
},
{
"text": "多云",
"temp_fc": 24,
"wind_class": "2级",
"wind_dir": "南风",
"rh": 65,
"prec_1h": 0.0,
"clouds": 40,
"data_time": "2026-02-12 16:00",
},
],
"alerts": [
{
"type": "大风",
"level": "蓝色",
"title": "北京市气象台发布大风蓝色预警",
"desc": "预计未来24小时将出现5-6级大风。",
}
],
"indexes": [
{
"name": "紫外线指数",
"brief": "",
"detail": "紫外线辐射强,建议涂擦SPF20左右的防晒霜。",
}
],
},
}
MOCK_DISTRICT_CSV = """district_id,province,city,district
110100,北京市,北京市,东城区
110101,北京市,北京市,西城区
110108,北京市,北京市,海淀区
310100,上海市,上海市,黄浦区
310101,上海市,上海市,徐汇区
"""
+108
View File
@@ -0,0 +1,108 @@
"""Tests for Baidu Weather API client."""
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
import pytest
from custom_components.hass_weather_baidu.api import (
BaiduWeatherApiClient,
BaiduWeatherApiError,
BaiduWeatherAuthError,
BaiduWeatherConnectionError,
async_fetch_district_data,
)
from .conftest import MOCK_AK, MOCK_DISTRICT_CSV, MOCK_WEATHER_RESPONSE
class TestBaiduWeatherApiClient:
"""Test BaiduWeatherApiClient."""
async def test_get_weather_by_district_success(self) -> None:
"""Test successful weather fetch by district ID."""
session = MagicMock(spec=aiohttp.ClientSession)
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = AsyncMock(return_value=MOCK_WEATHER_RESPONSE)
session.get = AsyncMock(return_value=mock_response)
session.get.return_value.__aenter__ = AsyncMock(
return_value=mock_response
)
session.get.return_value.__aexit__ = AsyncMock(return_value=False)
client = BaiduWeatherApiClient(session=session, ak=MOCK_AK)
with patch("asyncio.timeout"):
result = await client.async_get_weather_by_district("110108")
assert result is not None
assert "now" in result or "location" in result
async def test_get_weather_auth_error(self) -> None:
"""Test auth error handling."""
session = MagicMock(spec=aiohttp.ClientSession)
error_response = {"status": 211, "message": "AK无效"}
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = AsyncMock(return_value=error_response)
session.get = AsyncMock(return_value=mock_response)
client = BaiduWeatherApiClient(session=session, ak="invalid_ak")
with pytest.raises(BaiduWeatherAuthError):
with patch("asyncio.timeout"):
await client.async_get_weather_by_district("110108")
async def test_clean_abnormal_values(self) -> None:
"""Test that abnormal values are cleaned."""
session = MagicMock(spec=aiohttp.ClientSession)
response_data = {
"status": 0,
"result": {
"now": {
"temp": 25,
"pressure": 999999,
"wind_class": "暂无",
}
},
}
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock()
mock_response.json = AsyncMock(return_value=response_data)
session.get = AsyncMock(return_value=mock_response)
client = BaiduWeatherApiClient(session=session, ak=MOCK_AK)
with patch("asyncio.timeout"):
result = await client.async_get_weather_by_district("110108")
assert result["now"]["temp"] == 25
assert result["now"]["pressure"] is None
assert result["now"]["wind_class"] is None
class TestAsyncFetchDistrictData:
"""Test district data fetching."""
async def test_parse_district_csv(self) -> None:
"""Test CSV parsing returns correct structure."""
session = MagicMock(spec=aiohttp.ClientSession)
mock_response = AsyncMock()
mock_response.raise_for_status = MagicMock()
mock_response.text = AsyncMock(return_value=MOCK_DISTRICT_CSV)
session.get = AsyncMock(return_value=mock_response)
with patch("asyncio.timeout"):
result = await async_fetch_district_data(session)
assert "北京市" in result
assert "北京市" in result["北京市"]
assert "海淀区" in result["北京市"]["北京市"]
assert result["北京市"]["北京市"]["海淀区"] == "110108"
assert "上海市" in result
assert "黄浦区" in result["上海市"]["上海市"]