first commit
This commit is contained in:
@@ -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
Reference in New Issue
Block a user