3 Commits
Author SHA1 Message Date
zimonianhua c3f40b7369 直接暴露出实体便于LLM读取天气预报信息 2026-02-13 14:02:07 +08:00
zimonianhua b36d77b4da feat(weather): add singular get_forecast service and enhance entity attributes for LLM compatibility 2026-02-13 13:18:42 +08:00
zimonianhua 566f296835 fix(weather): remove duplicate visibility property and update manifest metadata
Remove deprecated `visibility` property in favor of `native_visibility`,
move `UnitOfLength` import to module-level, and update manifest.json
with correct codeowner, documentation URLs, weather dependency, and
bump version to 1.0.1.
2026-02-12 15:59:38 +08:00
7 changed files with 343 additions and 22 deletions
@@ -4,8 +4,11 @@ from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.components.weather import WeatherEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
@@ -15,6 +18,47 @@ _LOGGER = logging.getLogger(__name__)
type BaiduWeatherConfigEntry = ConfigEntry[BaiduWeatherCoordinator]
SERVICE_GET_FORECAST = "get_forecast"
async def _async_register_get_forecast_service(hass: HomeAssistant) -> None:
"""Register weather.get_forecast (singular) service if not already registered.
HA core only registers weather.get_forecasts (plural).
Some LLM integrations (e.g. Extended OpenAI Conversation) may call
the singular form, which causes a ServiceNotFound error.
This registers the singular form as an alias.
"""
if hass.services.has_service("weather", SERVICE_GET_FORECAST):
return
from homeassistant.components.weather import async_get_forecasts_service
from homeassistant.components.weather.const import DATA_COMPONENT
component = hass.data.get(DATA_COMPONENT)
if component is None:
_LOGGER.debug(
"Weather component not initialized, cannot register get_forecast service"
)
return
component.async_register_entity_service(
SERVICE_GET_FORECAST,
{
vol.Optional("type", default="daily"): vol.In(
("daily", "hourly", "twice_daily")
)
},
async_get_forecasts_service,
required_features=[
WeatherEntityFeature.FORECAST_DAILY,
WeatherEntityFeature.FORECAST_HOURLY,
WeatherEntityFeature.FORECAST_TWICE_DAILY,
],
supports_response=SupportsResponse.OPTIONAL,
)
_LOGGER.info("Registered weather.get_forecast service (singular form alias)")
async def async_setup_entry(
hass: HomeAssistant, entry: BaiduWeatherConfigEntry
@@ -35,6 +79,9 @@ async def async_setup_entry(
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Register weather.get_forecast (singular) service for LLM compatibility
await _async_register_get_forecast_service(hass)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
@@ -1,12 +1,12 @@
{
"domain": "hass_weather_baidu",
"name": "百度天气",
"codeowners": ["@your-github-username"],
"codeowners": ["@xyzmos"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/your-github-username/hass_weather_baidu",
"dependencies": ["weather"],
"documentation": "https://github.com/xyzmos/hass_weather_baidu",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/your-github-username/hass_weather_baidu/issues",
"issue_tracker": "https://github.com/xyzmos/hass_weather_baidu/issues",
"requirements": ["aiohttp>=3.8.0"],
"version": "1.0.0"
"version": "1.0.1"
}
+151 -4
View File
@@ -1,4 +1,4 @@
"""Sensor entities for Baidu Weather integration - Weather Alerts."""
"""Sensor entities for Baidu Weather integration - Weather Alerts & Forecasts."""
from __future__ import annotations
@@ -6,11 +6,10 @@ 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.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -20,12 +19,18 @@ from .const import (
CONF_LOCATION_NAME,
DOMAIN,
KEY_ALERTS,
KEY_INDEXES,
KEY_FORECASTS,
KEY_NOW,
)
from .coordinator import BaiduWeatherCoordinator
_LOGGER = logging.getLogger(__name__)
# Day labels for forecast sensors (index 0 = today)
_DAY_LABELS_CN = ["今天", "明天", "后天", "大后天", "第五天"]
_DAY_LABELS_EN = ["Today", "Tomorrow", "Day After Tomorrow", "In 3 Days", "In 4 Days"]
_DAY_KEYS = ["forecast_today", "forecast_tomorrow", "forecast_day2", "forecast_day3", "forecast_day4"]
async def async_setup_entry(
hass: HomeAssistant,
@@ -41,6 +46,14 @@ async def async_setup_entry(
BaiduWeatherAqiSensor(coordinator, entry, location_name),
]
# Create daily forecast sensors (today + next 4 days = 5 sensors)
for day_index in range(5):
entities.append(
BaiduWeatherDailyForecastSensor(
coordinator, entry, location_name, day_index
)
)
async_add_entities(entities)
@@ -187,3 +200,137 @@ class BaiduWeatherAqiSensor(
attrs["aqi_level"] = "严重污染"
return attrs
class BaiduWeatherDailyForecastSensor(
CoordinatorEntity[BaiduWeatherCoordinator], SensorEntity
):
"""Sensor entity for a single day's weather forecast.
Each instance represents one day (today, tomorrow, day after tomorrow, etc.).
The sensor state is a human-readable summary like "多云 12~20°C".
Extra attributes contain detailed forecast fields so that voice assistants
and LLM integrations can read the forecast directly from entity state.
"""
_attr_has_entity_name = True
_attr_icon = "mdi:weather-partly-cloudy"
_attr_attribution = ATTRIBUTION
def __init__(
self,
coordinator: BaiduWeatherCoordinator,
entry: ConfigEntry,
location_name: str,
day_index: int,
) -> None:
"""Initialize the daily forecast sensor.
Args:
day_index: 0 = today, 1 = tomorrow, 2 = day after tomorrow, etc.
"""
super().__init__(coordinator)
self._entry = entry
self._day_index = day_index
self._attr_translation_key = _DAY_KEYS[day_index]
self._attr_unique_id = f"{DOMAIN}_{entry.entry_id}_{_DAY_KEYS[day_index]}"
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/",
)
def _get_forecast_data(self) -> dict[str, Any] | None:
"""Get the forecast data for this day by index."""
if self.coordinator.data is None:
return None
forecasts = self.coordinator.data.get(KEY_FORECASTS, [])
if self._day_index < len(forecasts):
return forecasts[self._day_index]
return None
@property
def native_value(self) -> str | None:
"""Return a human-readable weather summary for this day.
Format: "多云 12~20°C" or "晴转多云 8~18°C"
This makes it easy for voice assistants to read aloud.
"""
fc = self._get_forecast_data()
if fc is None:
return None
text_day = fc.get("text_day", "")
text_night = fc.get("text_night", "")
high = fc.get("high")
low = fc.get("low")
# Build condition text
if text_day and text_night and text_day != text_night:
condition_text = f"{text_day}{text_night}"
elif text_day:
condition_text = text_day
elif text_night:
condition_text = text_night
else:
condition_text = "未知"
# Build temperature range
if low is not None and high is not None:
temp_text = f"{low}~{high}°C"
elif high is not None:
temp_text = f"最高{high}°C"
elif low is not None:
temp_text = f"最低{low}°C"
else:
temp_text = ""
if temp_text:
return f"{condition_text} {temp_text}"
return condition_text
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return detailed forecast attributes for this day.
These attributes are designed to be easily readable by LLM integrations.
"""
attrs: dict[str, Any] = {}
fc = self._get_forecast_data()
if fc is None:
return attrs
# Date
if fc.get("date"):
attrs["date"] = fc["date"]
# Day label (今天/明天/后天...)
attrs["day_label"] = _DAY_LABELS_CN[self._day_index]
# Conditions
if fc.get("text_day"):
attrs["condition_day"] = fc["text_day"]
if fc.get("text_night"):
attrs["condition_night"] = fc["text_night"]
# Temperatures
if fc.get("high") is not None:
attrs["temperature_high"] = fc["high"]
if fc.get("low") is not None:
attrs["temperature_low"] = fc["low"]
# Wind - Day
if fc.get("wc_day"):
attrs["wind_class_day"] = fc["wc_day"]
if fc.get("wd_day"):
attrs["wind_direction_day"] = fc["wd_day"]
# Wind - Night
if fc.get("wc_night"):
attrs["wind_class_night"] = fc["wc_night"]
if fc.get("wd_night"):
attrs["wind_direction_night"] = fc["wd_night"]
return attrs
@@ -69,6 +69,21 @@
},
"aqi": {
"name": "空气质量指数"
},
"forecast_today": {
"name": "今天天气预报"
},
"forecast_tomorrow": {
"name": "明天天气预报"
},
"forecast_day2": {
"name": "后天天气预报"
},
"forecast_day3": {
"name": "大后天天气预报"
},
"forecast_day4": {
"name": "第五天天气预报"
}
}
}
@@ -69,6 +69,21 @@
},
"aqi": {
"name": "Air Quality Index"
},
"forecast_today": {
"name": "Today's Weather Forecast"
},
"forecast_tomorrow": {
"name": "Tomorrow's Weather Forecast"
},
"forecast_day2": {
"name": "Day After Tomorrow Forecast"
},
"forecast_day3": {
"name": "3-Day Forecast"
},
"forecast_day4": {
"name": "4-Day Forecast"
}
}
}
@@ -69,6 +69,21 @@
},
"aqi": {
"name": "空气质量指数"
},
"forecast_today": {
"name": "今天天气预报"
},
"forecast_tomorrow": {
"name": "明天天气预报"
},
"forecast_day2": {
"name": "后天天气预报"
},
"forecast_day3": {
"name": "大后天天气预报"
},
"forecast_day4": {
"name": "第五天天气预报"
}
}
}
+94 -12
View File
@@ -13,6 +13,7 @@ from homeassistant.components.weather import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfLength,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
@@ -27,8 +28,10 @@ from .const import (
CONDITION_MAP,
CONF_LOCATION_NAME,
DOMAIN,
KEY_ALERTS,
KEY_FORECAST_HOURS,
KEY_FORECASTS,
KEY_INDEXES,
KEY_NOW,
WIND_BEARING_MAP,
WIND_SPEED_MAP,
@@ -144,26 +147,17 @@ class BaiduWeatherEntity(CoordinatorEntity[BaiduWeatherCoordinator], WeatherEnti
return self._now_data.get("clouds")
@property
def visibility(self) -> float | None:
"""Return the visibility in km."""
def native_visibility(self) -> float | None:
"""Return 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
@@ -183,7 +177,13 @@ class BaiduWeatherEntity(CoordinatorEntity[BaiduWeatherCoordinator], WeatherEnti
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return extra state attributes for voice assistants."""
"""Return extra state attributes for voice assistants.
Includes forecast data so that LLM integrations (e.g. Extended OpenAI
Conversation) can read forecasts directly from entity state without
needing to call the weather.get_forecasts service (which requires
return_response=True that many LLM integrations don't support).
"""
attrs: dict[str, Any] = {}
now = self._now_data
@@ -210,6 +210,88 @@ class BaiduWeatherEntity(CoordinatorEntity[BaiduWeatherCoordinator], WeatherEnti
if now.get("uptime") is not None:
attrs["update_time"] = now["uptime"]
# --- Daily forecast (for voice assistants / LLM) ---
if self.coordinator.data is not None:
forecasts_raw = self.coordinator.data.get(KEY_FORECASTS, [])
if forecasts_raw:
forecast_list = []
for fc in forecasts_raw:
date_str = fc.get("date", "")
entry: dict[str, Any] = {"date": date_str}
if fc.get("text_day"):
entry["condition_day"] = fc["text_day"]
if fc.get("text_night"):
entry["condition_night"] = fc["text_night"]
if fc.get("high") is not None:
entry["temperature_high"] = fc["high"]
if fc.get("low") is not None:
entry["temperature_low"] = fc["low"]
if fc.get("wc_day"):
entry["wind_class_day"] = fc["wc_day"]
if fc.get("wd_day"):
entry["wind_direction_day"] = fc["wd_day"]
if fc.get("wc_night"):
entry["wind_class_night"] = fc["wc_night"]
if fc.get("wd_night"):
entry["wind_direction_night"] = fc["wd_night"]
forecast_list.append(entry)
attrs["forecast_daily"] = forecast_list
# --- Hourly forecast ---
hours_raw = self.coordinator.data.get(KEY_FORECAST_HOURS, [])
if hours_raw:
hourly_list = []
for hour in hours_raw:
entry = {}
if hour.get("data_time"):
entry["datetime"] = hour["data_time"]
if hour.get("text"):
entry["condition"] = hour["text"]
if hour.get("temp_fc") is not None:
entry["temperature"] = hour["temp_fc"]
if hour.get("rh") is not None:
entry["humidity"] = hour["rh"]
if hour.get("wind_class"):
entry["wind_class"] = hour["wind_class"]
if hour.get("wind_dir"):
entry["wind_direction"] = hour["wind_dir"]
if hour.get("prec_1h") is not None:
entry["precipitation"] = hour["prec_1h"]
hourly_list.append(entry)
attrs["forecast_hourly"] = hourly_list
# --- Weather alerts ---
alerts = self.coordinator.data.get(KEY_ALERTS, [])
if alerts:
alert_list = []
for alert in alerts:
entry = {}
if alert.get("title"):
entry["title"] = alert["title"]
if alert.get("type"):
entry["type"] = alert["type"]
if alert.get("level"):
entry["level"] = alert["level"]
if alert.get("desc"):
entry["description"] = alert["desc"]
alert_list.append(entry)
attrs["alerts"] = alert_list
# --- Life indexes ---
indexes = self.coordinator.data.get(KEY_INDEXES, [])
if indexes:
index_list = []
for idx in indexes:
entry = {}
if idx.get("name"):
entry["name"] = idx["name"]
if idx.get("brief"):
entry["brief"] = idx["brief"]
if idx.get("detail"):
entry["detail"] = idx["detail"]
index_list.append(entry)
attrs["life_indexes"] = index_list
return attrs
async def async_forecast_daily(self) -> list[Forecast] | None: