Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions custom_components/ninebot/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 = []
Expand Down
31 changes: 30 additions & 1 deletion custom_components/ninebot/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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",
Expand All @@ -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),
),
}
),
)
5 changes: 5 additions & 0 deletions custom_components/ninebot/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
101 changes: 99 additions & 2 deletions custom_components/ninebot/coordinator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import asyncio
from datetime import timedelta
import logging
from typing import Any
Expand All @@ -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__)

Expand All @@ -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)
)
Expand All @@ -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)
Expand Down Expand Up @@ -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
10 changes: 9 additions & 1 deletion custom_components/ninebot/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
10 changes: 9 additions & 1 deletion custom_components/ninebot/translations/zh-Hans.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 秒,保持原行为。"
}
}
}
Expand Down