diff --git a/custom_components/ninebot/api.py b/custom_components/ninebot/api.py index 86d26f1..270dbd9 100644 --- a/custom_components/ninebot/api.py +++ b/custom_components/ninebot/api.py @@ -61,16 +61,21 @@ async def async_get_device_state(self, sn: str, *, month: str | None = None) -> month = month or datetime.now(UTC).strftime("%Y%m") state = await self.async_get_device_status(sn) try: - travel = await self._async_run_json_command( - ["travel", sn, "--month", month, "--json"] - ) + travel = await self.async_get_device_travel(sn, month=month) except NinebotApiConnectionError as err: LOGGER.debug("Failed to fetch Ninebot travel data for %s: %s", sn, err) else: if isinstance(travel, dict): - state.update(self._normalize_travel(travel)) + state.update(self.normalize_travel(travel)) return state + async def async_get_device_travel(self, sn: str, *, month: str | None = None) -> dict[str, Any]: + month = month or datetime.now(UTC).strftime("%Y%m") + payload = await self._async_run_json_command( + ["travel", sn, "--month", month, "--json"] + ) + return payload if isinstance(payload, dict) else {} + async def async_get_all_device_payloads(self) -> list[dict[str, Any]]: async with self._cycle_lock: devices = await self.async_get_device_list() @@ -207,7 +212,7 @@ def _normalize_status(status: dict[str, Any]) -> dict[str, Any]: return state @staticmethod - def _normalize_travel(travel: dict[str, Any]) -> dict[str, Any]: + def normalize_travel(travel: dict[str, Any]) -> dict[str, Any]: rides = travel.get("list") if not isinstance(rides, list): rides = [] diff --git a/custom_components/ninebot/config_flow.py b/custom_components/ninebot/config_flow.py index 32f0820..84d4999 100644 --- a/custom_components/ninebot/config_flow.py +++ b/custom_components/ninebot/config_flow.py @@ -13,10 +13,15 @@ from .api import NinebotApiAuthError, NinebotApiConnectionError, NinebotCliClient from .const import ( CONF_BUSINESS_UID, + CONF_DEVICE_DELAY, + CONF_KEEP_LAST_DATA_ON_ERROR, CONF_PASSWORD, CONF_POLL_INTERVAL, + CONF_REQUEST_DELAY, CONF_USERNAME, + DEFAULT_DEVICE_DELAY, DEFAULT_POLL_INTERVAL, + DEFAULT_REQUEST_DELAY, DOMAIN, NINEBOT_STORAGE_DIR, ) @@ -204,6 +209,18 @@ async def async_step_init( CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, ) + keep_last_data_on_error = self._config_entry.options.get( + CONF_KEEP_LAST_DATA_ON_ERROR, + False, + ) + request_delay = self._config_entry.options.get( + CONF_REQUEST_DELAY, + DEFAULT_REQUEST_DELAY, + ) + device_delay = self._config_entry.options.get( + CONF_DEVICE_DELAY, + DEFAULT_DEVICE_DELAY, + ) return self.async_show_form( step_id="init", @@ -212,7 +229,19 @@ async def async_step_init( vol.Required(CONF_POLL_INTERVAL, default=poll_interval): vol.All( vol.Coerce(int), vol.Range(min=30, max=86400), - ) + ), + vol.Optional( + CONF_KEEP_LAST_DATA_ON_ERROR, + default=keep_last_data_on_error, + ): bool, + vol.Optional(CONF_REQUEST_DELAY, default=request_delay): vol.All( + vol.Coerce(int), + vol.Range(min=0, max=30), + ), + vol.Optional(CONF_DEVICE_DELAY, default=device_delay): vol.All( + vol.Coerce(int), + vol.Range(min=0, max=30), + ), } ), ) diff --git a/custom_components/ninebot/const.py b/custom_components/ninebot/const.py index 54c6c1d..ba6d0eb 100644 --- a/custom_components/ninebot/const.py +++ b/custom_components/ninebot/const.py @@ -4,8 +4,13 @@ CONF_USERNAME = "username" CONF_PASSWORD = "password" CONF_BUSINESS_UID = "business_uid" +CONF_KEEP_LAST_DATA_ON_ERROR = "keep_last_data_on_error" CONF_POLL_INTERVAL = "poll_interval" +CONF_REQUEST_DELAY = "request_delay" +CONF_DEVICE_DELAY = "device_delay" DEFAULT_POLL_INTERVAL = 120 +DEFAULT_REQUEST_DELAY = 0 +DEFAULT_DEVICE_DELAY = 0 API_TIMEOUT = 30 NINEBOT_STORAGE_DIR = "ninebot" NINECLI_MODULE = "ninecli" diff --git a/custom_components/ninebot/coordinator.py b/custom_components/ninebot/coordinator.py index 94a011c..1562833 100644 --- a/custom_components/ninebot/coordinator.py +++ b/custom_components/ninebot/coordinator.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any @@ -10,7 +11,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import NinebotApiAuthError, NinebotApiConnectionError, NinebotCliClient -from .const import CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL, DOMAIN +from .const import ( + CONF_DEVICE_DELAY, + CONF_KEEP_LAST_DATA_ON_ERROR, + CONF_POLL_INTERVAL, + CONF_REQUEST_DELAY, + DEFAULT_DEVICE_DELAY, + DEFAULT_POLL_INTERVAL, + DEFAULT_REQUEST_DELAY, + DOMAIN, +) LOGGER = logging.getLogger(__package__) @@ -28,6 +38,19 @@ def __init__( ) -> None: self.config_entry = entry self._client = client + self._keep_last_data_on_error = bool( + entry.options.get(CONF_KEEP_LAST_DATA_ON_ERROR, False) + ) + self._request_delay = _entry_delay( + entry, + CONF_REQUEST_DELAY, + DEFAULT_REQUEST_DELAY, + ) + self._device_delay = _entry_delay( + entry, + CONF_DEVICE_DELAY, + DEFAULT_DEVICE_DELAY, + ) update_interval = timedelta( seconds=entry.options.get(CONF_POLL_INTERVAL, DEFAULT_POLL_INTERVAL) ) @@ -40,15 +63,75 @@ def __init__( async def _async_update_data(self) -> dict[str, Any]: try: - payloads = await self._client.async_get_all_device_payloads() + payloads = await self._async_get_device_payloads() except NinebotApiAuthError as err: raise ConfigEntryAuthFailed from err except NinebotApiConnectionError as err: + if self._keep_last_data_on_error and self.data: + LOGGER.warning("Keeping previous Ninebot data after update failure: %s", err) + return self.data raise UpdateFailed(str(err) or "Failed to fetch Ninebot data") from err merged_devices = {payload["sn"]: payload for payload in payloads} return {"devices": merged_devices} + async def _async_get_device_payloads(self) -> list[dict[str, Any]]: + devices = await self._client.async_get_device_list() + await self._async_request_delay() + previous_devices = self._previous_devices + results: list[dict[str, Any]] = [] + + for index, device in enumerate(devices): + sn = device.get("sn") + if not isinstance(sn, str) or not sn: + continue + + if index > 0: + await self._async_device_delay() + + try: + state = await self._async_get_device_state(sn) + except NinebotApiConnectionError as err: + previous_payload = previous_devices.get(sn) + if self._keep_last_data_on_error and isinstance(previous_payload, dict): + LOGGER.warning( + "Keeping previous Ninebot data for %s after update failure: %s", + sn, + err, + ) + results.append(previous_payload) + continue + raise + + results.append({ + "sn": sn, + "info": device, + "state": state, + }) + + return results + + async def _async_get_device_state(self, sn: str) -> dict[str, Any]: + state = await self._client.async_get_device_status(sn) + await self._async_request_delay() + + try: + travel = await self._client.async_get_device_travel(sn) + except NinebotApiConnectionError as err: + LOGGER.debug("Failed to fetch Ninebot travel data for %s: %s", sn, err) + else: + if isinstance(travel, dict): + state.update(self._client.normalize_travel(travel)) + return state + + async def _async_request_delay(self) -> None: + if self._request_delay > 0: + await asyncio.sleep(self._request_delay) + + async def _async_device_delay(self) -> None: + if self._device_delay > 0: + await asyncio.sleep(self._device_delay) + async def async_request_device_status_refresh(self, sn: str) -> None: try: status = await self._client.async_get_device_status(sn) @@ -90,3 +173,17 @@ async def async_request_device_status_refresh(self, sn: str) -> None: sn: updated_device, }, }) + + @property + def _previous_devices(self) -> dict[str, Any]: + if not isinstance(self.data, dict): + return {} + devices = self.data.get("devices") + return devices if isinstance(devices, dict) else {} + + +def _entry_delay(entry: ConfigEntry, key: str, default: int) -> int: + try: + return max(0, int(entry.options.get(key, default))) + except (TypeError, ValueError): + return default diff --git a/custom_components/ninebot/translations/en.json b/custom_components/ninebot/translations/en.json index 5343c93..b40d3f2 100644 --- a/custom_components/ninebot/translations/en.json +++ b/custom_components/ninebot/translations/en.json @@ -30,7 +30,15 @@ "step": { "init": { "data": { - "poll_interval": "Polling interval (seconds)" + "poll_interval": "Polling interval (seconds)", + "keep_last_data_on_error": "Keep last data when updates fail", + "request_delay": "Request delay (seconds)", + "device_delay": "Device delay (seconds)" + }, + "data_description": { + "keep_last_data_on_error": "Optional. When enabled, entities keep the last successful data if the Ninebot API update fails because of network or service errors. Authentication failures still trigger re-authentication.", + "request_delay": "Seconds to wait between Ninebot API requests. Defaults to 0 seconds to keep the existing behavior.", + "device_delay": "Seconds to wait between different vehicles. Defaults to 0 seconds to keep the existing behavior." } } } diff --git a/custom_components/ninebot/translations/zh-Hans.json b/custom_components/ninebot/translations/zh-Hans.json index 37fefcd..c0f6f5c 100644 --- a/custom_components/ninebot/translations/zh-Hans.json +++ b/custom_components/ninebot/translations/zh-Hans.json @@ -30,7 +30,15 @@ "step": { "init": { "data": { - "poll_interval": "轮询间隔(秒)" + "poll_interval": "轮询间隔(秒)", + "keep_last_data_on_error": "更新失败时保留上次数据", + "request_delay": "请求间隔(秒)", + "device_delay": "设备间隔(秒)" + }, + "data_description": { + "keep_last_data_on_error": "可选。开启后,九号接口因网络或服务异常刷新失败时,实体会继续显示上一次成功更新的数据。认证失败仍会触发重新认证。", + "request_delay": "每个九号接口请求之间等待的秒数。默认 0 秒,保持原行为。", + "device_delay": "不同车辆之间等待的秒数。默认 0 秒,保持原行为。" } } }