first commit
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
*.egg
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
*.log
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,148 @@
|
||||
# 百度天气 - Home Assistant 自定义集成
|
||||
|
||||
[](https://github.com/hacs/integration)
|
||||
[](https://github.com/xyzmos/hass_weather_baidu/releases)
|
||||
[](LICENSE)
|
||||
|
||||
基于百度地图天气服务的 Home Assistant 天气集成插件,支持获取实时天气、逐小时预报、7天预报及气象预警信息。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- 🌤️ **实时天气**:温度、体感温度、湿度、风速、风向、云量、能见度、气压等
|
||||
- 📊 **逐小时预报**:未来24小时逐小时天气预报
|
||||
- 📅 **7天预报**:未来7天每日天气预报
|
||||
- ⚠️ **气象预警**:独立传感器实体,实时推送预警信息
|
||||
- 🌬️ **空气质量**:AQI、PM2.5、PM10、NO₂、SO₂、O₃、CO
|
||||
- 🗣️ **语音助手支持**:所有关键属性均可被语音助手读取
|
||||
- 🌐 **多语言支持**:支持中文和英文界面
|
||||
- 🔧 **双配置模式**:
|
||||
- 按行政区划代码选择(省/市/区县级联)
|
||||
- 按 Home Assistant 地点经纬度选择
|
||||
|
||||
## 📋 前置要求
|
||||
|
||||
1. 百度地图开放平台账号
|
||||
2. 天气服务 API Key(AK)— 在[百度地图开放平台 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)
|
||||
@@ -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
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "百度天气",
|
||||
"render_readme": true,
|
||||
"domains": ["weather", "sensor"],
|
||||
"country": ["CN"],
|
||||
"homeassistant": "2024.1.0",
|
||||
"iot_class": "cloud_polling"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Baidu Weather integration."""
|
||||
@@ -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,上海市,上海市,徐汇区
|
||||
"""
|
||||
@@ -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["上海市"]["上海市"]
|
||||
Reference in New Issue
Block a user