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