diff --git a/.strict-typing b/.strict-typing index 5e1549256616c9..3610e3d6ebe640 100644 --- a/.strict-typing +++ b/.strict-typing @@ -199,6 +199,7 @@ homeassistant.components.enphase_envoy.* homeassistant.components.eq3btsmart.* homeassistant.components.esphome.* homeassistant.components.event.* +homeassistant.components.eveonline.* homeassistant.components.evil_genius_labs.* homeassistant.components.evohome.* homeassistant.components.faa_delays.* diff --git a/CODEOWNERS b/CODEOWNERS index 1662d1b3df0cc9..12c8e6855d9e21 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -505,6 +505,8 @@ CLAUDE.md @home-assistant/core /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/event/ @home-assistant/core /tests/components/event/ @home-assistant/core +/homeassistant/components/eveonline/ @ronaldvdmeer +/tests/components/eveonline/ @ronaldvdmeer /homeassistant/components/evohome/ @zxdavb /tests/components/evohome/ @zxdavb /homeassistant/components/ezviz/ @RenierM26 diff --git a/homeassistant/components/eveonline/__init__.py b/homeassistant/components/eveonline/__init__.py new file mode 100644 index 00000000000000..2f712da9347602 --- /dev/null +++ b/homeassistant/components/eveonline/__init__.py @@ -0,0 +1,55 @@ +"""The Eve Online integration.""" + +from __future__ import annotations + +from eveonline import EveOnlineClient + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import CONF_CHARACTER_ID, CONF_CHARACTER_NAME, DOMAIN +from .coordinator import EveOnlineConfigEntry, EveOnlineCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: EveOnlineConfigEntry) -> bool: + """Set up Eve Online from a config entry.""" + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + + session = OAuth2Session(hass, entry, implementation) + + auth = AsyncConfigEntryAuth(aiohttp_client.async_get_clientsession(hass), session) + client = EveOnlineClient(auth=auth) + + character_id: int = entry.data[CONF_CHARACTER_ID] + character_name: str = entry.data[CONF_CHARACTER_NAME] + + coordinator = EveOnlineCoordinator( + hass, entry, client, character_id, character_name + ) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EveOnlineConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/eveonline/api.py b/homeassistant/components/eveonline/api.py new file mode 100644 index 00000000000000..4418831a110318 --- /dev/null +++ b/homeassistant/components/eveonline/api.py @@ -0,0 +1,48 @@ +"""API helpers for the Eve Online integration.""" + +from __future__ import annotations + +from typing import cast + +from aiohttp import ClientError, ClientSession +from eveonline.auth import AbstractAuth + +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + +from .const import DOMAIN + + +class AsyncConfigEntryAuth(AbstractAuth): + """Provide Eve Online authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: OAuth2Session, + ) -> None: + """Initialize Eve Online auth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + try: + await self._oauth_session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + ) from err + except (OAuth2TokenRequestTransientError, ClientError) as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="token_refresh_failed", + translation_placeholders={"error": str(err)}, + ) from err + return cast(str, self._oauth_session.token["access_token"]) diff --git a/homeassistant/components/eveonline/application_credentials.py b/homeassistant/components/eveonline/application_credentials.py new file mode 100644 index 00000000000000..dc77ebb99c0161 --- /dev/null +++ b/homeassistant/components/eveonline/application_credentials.py @@ -0,0 +1,14 @@ +"""Application credentials for the Eve Online integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return the Eve Online authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/eveonline/config_flow.py b/homeassistant/components/eveonline/config_flow.py new file mode 100644 index 00000000000000..a669601fc671f1 --- /dev/null +++ b/homeassistant/components/eveonline/config_flow.py @@ -0,0 +1,74 @@ +"""Config flow for the Eve Online integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import jwt + +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +from .const import CONF_CHARACTER_ID, CONF_CHARACTER_NAME, DOMAIN, SCOPES + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Handle OAuth2 config flow for Eve Online. + + Each config entry represents one authenticated character. + Multiple characters can be added as separate entries. + """ + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return _LOGGER + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data to include in the authorize URL.""" + return {"scope": " ".join(SCOPES)} + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow. + + Decode the Eve SSO JWT access token to extract character_id and + character_name, then create a config entry for that character. + """ + try: + token = data["token"]["access_token"] + character_info = _decode_eve_jwt(token) + except ValueError, KeyError, jwt.DecodeError: + return self.async_abort(reason="oauth_error") + + character_id = character_info[CONF_CHARACTER_ID] + character_name = character_info[CONF_CHARACTER_NAME] + + await self.async_set_unique_id(str(character_id)) + self._abort_if_unique_id_configured() + + data[CONF_CHARACTER_ID] = character_id + data[CONF_CHARACTER_NAME] = character_name + + return self.async_create_entry( + title=character_name, + data=data, + ) + + +def _decode_eve_jwt(token: str) -> dict[str, Any]: + """Decode an Eve SSO JWT to extract character info.""" + decoded = jwt.decode(token, options={"verify_signature": False}) + sub = decoded.get("sub", "") + sub_parts = sub.split(":") + if len(sub_parts) != 3 or sub_parts[0] != "CHARACTER" or sub_parts[1] != "EVE": + raise ValueError(sub) + return { + CONF_CHARACTER_ID: int(sub_parts[2]), + CONF_CHARACTER_NAME: decoded.get("name", "Unknown"), + } diff --git a/homeassistant/components/eveonline/const.py b/homeassistant/components/eveonline/const.py new file mode 100644 index 00000000000000..97738491c647ca --- /dev/null +++ b/homeassistant/components/eveonline/const.py @@ -0,0 +1,24 @@ +"""Constants for the Eve Online integration.""" + +from typing import Final + +DOMAIN: Final = "eveonline" + +CONF_CHARACTER_ID: Final = "character_id" +CONF_CHARACTER_NAME: Final = "character_name" + +OAUTH2_AUTHORIZE: Final = "https://login.eveonline.com/v2/oauth/authorize" +OAUTH2_TOKEN: Final = "https://login.eveonline.com/v2/oauth/token" + +SCOPES: Final[list[str]] = [ + "esi-characters.read_fatigue.v1", + "esi-industry.read_character_jobs.v1", + "esi-location.read_location.v1", + "esi-location.read_online.v1", + "esi-location.read_ship_type.v1", + "esi-mail.read_mail.v1", + "esi-markets.read_character_orders.v1", + "esi-skills.read_skillqueue.v1", + "esi-skills.read_skills.v1", + "esi-wallet.read_character_wallet.v1", +] diff --git a/homeassistant/components/eveonline/coordinator.py b/homeassistant/components/eveonline/coordinator.py new file mode 100644 index 00000000000000..0a8282ecffdcac --- /dev/null +++ b/homeassistant/components/eveonline/coordinator.py @@ -0,0 +1,200 @@ +"""Coordinator for the Eve Online integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from datetime import timedelta +import logging +from typing import Any + +import aiohttp +from eveonline import EveOnlineClient, EveOnlineError +from eveonline.models import ( + CharacterLocation, + CharacterOnlineStatus, + CharacterShip, + CharacterSkillsSummary, + IndustryJob, + JumpFatigue, + MailLabelsSummary, + MarketOrder, + SkillQueueEntry, + WalletBalance, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SCAN_INTERVAL = 60 + + +@dataclass +class EveOnlineData: + """Eve Online character data.""" + + character_id: int + character_name: str + character_online: CharacterOnlineStatus | None = None + wallet_balance: WalletBalance | None = None + skill_queue: list[SkillQueueEntry] = field(default_factory=list) + location: CharacterLocation | None = None + ship: CharacterShip | None = None + skills: CharacterSkillsSummary | None = None + mail_labels: MailLabelsSummary | None = None + industry_jobs: list[IndustryJob] = field(default_factory=list) + market_orders: list[MarketOrder] = field(default_factory=list) + jump_fatigue: JumpFatigue | None = None + resolved_names: dict[int, str] = field(default_factory=dict) + + +type EveOnlineConfigEntry = ConfigEntry[EveOnlineCoordinator] + + +class EveOnlineCoordinator(DataUpdateCoordinator[EveOnlineData]): + """Coordinator to poll Eve Online server and character data.""" + + config_entry: EveOnlineConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: EveOnlineConfigEntry, + client: EveOnlineClient, + character_id: int, + character_name: str, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + config_entry=entry, + ) + self.client = client + self.character_id = character_id + self.character_name = character_name + + async def _async_update_data(self) -> EveOnlineData: + """Fetch character data from ESI.""" + try: + character_online = await self.client.async_get_character_online( + self.character_id + ) + except (EveOnlineError, aiohttp.ClientError) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err + wallet_balance = await self._fetch_optional( + self.client.async_get_wallet_balance, self.character_id + ) + skill_queue = await self._fetch_list( + self.client.async_get_skill_queue, self.character_id + ) + location = await self._fetch_optional( + self.client.async_get_character_location, self.character_id + ) + ship = await self._fetch_optional( + self.client.async_get_character_ship, self.character_id + ) + skills = await self._fetch_optional( + self.client.async_get_skills, self.character_id + ) + mail_labels = await self._fetch_optional( + self.client.async_get_mail_labels, self.character_id + ) + industry_jobs = await self._fetch_list( + self.client.async_get_industry_jobs, self.character_id + ) + market_orders = await self._fetch_list( + self.client.async_get_market_orders, self.character_id + ) + jump_fatigue = await self._fetch_optional( + self.client.async_get_jump_fatigue, self.character_id + ) + + resolved_names = await self._resolve_names( + location, ship, skill_queue, industry_jobs, market_orders + ) + + return EveOnlineData( + character_id=self.character_id, + character_name=self.character_name, + character_online=character_online, + wallet_balance=wallet_balance, + skill_queue=skill_queue, + location=location, + ship=ship, + skills=skills, + mail_labels=mail_labels, + industry_jobs=industry_jobs, + market_orders=market_orders, + jump_fatigue=jump_fatigue, + resolved_names=resolved_names, + ) + + async def _fetch_optional[T]( + self, + method: Callable[..., Awaitable[T]], + *args: Any, + ) -> T | None: + """Fetch an optional endpoint, returning None on failure.""" + try: + return await method(*args) + except (EveOnlineError, aiohttp.ClientError) as err: + _LOGGER.debug("Failed to fetch %s: %s", method.__name__, err) + return None + + async def _fetch_list[T]( + self, + method: Callable[..., Awaitable[list[T]]], + *args: Any, + ) -> list[T]: + """Fetch a list endpoint, returning empty list on failure.""" + try: + return await method(*args) + except (EveOnlineError, aiohttp.ClientError) as err: + _LOGGER.debug("Failed to fetch %s: %s", method.__name__, err) + return [] + + async def _resolve_names( + self, + location: CharacterLocation | None, + ship: CharacterShip | None, + skill_queue: list[SkillQueueEntry], + industry_jobs: list[IndustryJob], + market_orders: list[MarketOrder], + ) -> dict[int, str]: + """Resolve numeric IDs to human-readable names in a single API call.""" + ids: set[int] = set() + + if location: + ids.add(location.solar_system_id) + if ship: + ids.add(ship.ship_type_id) + if skill_queue: + ids.add(skill_queue[0].skill_id) + for job in industry_jobs: + ids.add(job.blueprint_type_id) + if job.product_type_id: + ids.add(job.product_type_id) + for order in market_orders: + ids.add(order.type_id) + + if not ids: + return {} + + try: + resolved = await self.client.async_resolve_names(list(ids)) + return {entry.id: entry.name for entry in resolved} + except (EveOnlineError, aiohttp.ClientError) as err: + _LOGGER.debug("Failed to resolve names: %s", err) + return {} diff --git a/homeassistant/components/eveonline/entity.py b/homeassistant/components/eveonline/entity.py new file mode 100644 index 00000000000000..4453b7bda71503 --- /dev/null +++ b/homeassistant/components/eveonline/entity.py @@ -0,0 +1,38 @@ +"""Base entity classes for the Eve Online integration.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import EveOnlineCoordinator + + +class EveOnlineEntity(CoordinatorEntity[EveOnlineCoordinator]): + """Base class for all Eve Online entities.""" + + _attr_has_entity_name = True + + +class EveOnlineCharacterEntity(EveOnlineEntity): + """Base class for Eve Online character entities.""" + + def __init__( + self, + coordinator: EveOnlineCoordinator, + key: str, + ) -> None: + """Initialize character entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.character_id}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.character_id))}, + name=coordinator.character_name, + manufacturer="CCP Games", + model="Eve Online Character", + entry_type=DeviceEntryType.SERVICE, + configuration_url=( + f"https://evewho.com/character/{coordinator.character_id}" + ), + ) diff --git a/homeassistant/components/eveonline/icons.json b/homeassistant/components/eveonline/icons.json new file mode 100644 index 00000000000000..f0a558758060c2 --- /dev/null +++ b/homeassistant/components/eveonline/icons.json @@ -0,0 +1,48 @@ +{ + "entity": { + "sensor": { + "buy_orders": { + "default": "mdi:cart-arrow-down" + }, + "current_skill_finish": { + "default": "mdi:clock-end" + }, + "current_training_skill": { + "default": "mdi:book-open-variant" + }, + "industry_jobs": { + "default": "mdi:factory" + }, + "jump_fatigue": { + "default": "mdi:clock-alert-outline" + }, + "location": { + "default": "mdi:map-marker" + }, + "next_industry_finish": { + "default": "mdi:clock-end" + }, + "sell_orders": { + "default": "mdi:cart-arrow-up" + }, + "ship": { + "default": "mdi:rocket-launch" + }, + "skill_queue_count": { + "default": "mdi:format-list-numbered" + }, + "total_sp": { + "default": "mdi:school" + }, + "unallocated_sp": { + "default": "mdi:school-outline" + }, + "unread_mail": { + "default": "mdi:email" + }, + "wallet_balance": { + "default": "mdi:wallet" + } + } + } +} diff --git a/homeassistant/components/eveonline/manifest.json b/homeassistant/components/eveonline/manifest.json new file mode 100644 index 00000000000000..f8d29a6e1fbef7 --- /dev/null +++ b/homeassistant/components/eveonline/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "eveonline", + "name": "Eve Online", + "codeowners": ["@ronaldvdmeer"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/eveonline", + "integration_type": "service", + "iot_class": "cloud_polling", + "loggers": ["eveonline"], + "quality_scale": "bronze", + "requirements": ["python-eveonline==0.4.0"] +} diff --git a/homeassistant/components/eveonline/quality_scale.yaml b/homeassistant/components/eveonline/quality_scale.yaml new file mode 100644 index 00000000000000..2452ce83f5c5bb --- /dev/null +++ b/homeassistant/components/eveonline/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not provide service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not provide service actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters beyond OAuth2 credentials. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: todo + comment: Will be added in a follow-up PR. + test-coverage: done + + # Gold + devices: done + diagnostics: + status: todo + comment: Will be added in a follow-up PR. + discovery: + status: exempt + comment: Eve Online is a cloud service — no local discovery possible. + discovery-update-info: + status: exempt + comment: Eve Online is a cloud service — no local discovery possible. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: Eve Online is a cloud service and does not integrate physical devices. + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Characters are added via config entries, not discovered dynamically. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: Will be added in a follow-up PR. + repair-issues: + status: exempt + comment: Integration does not raise repair issues. + stale-devices: + status: exempt + comment: Devices represent EVE characters tied to config entries — they are never stale. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/eveonline/sensor.py b/homeassistant/components/eveonline/sensor.py new file mode 100644 index 00000000000000..2b575d62eb9740 --- /dev/null +++ b/homeassistant/components/eveonline/sensor.py @@ -0,0 +1,189 @@ +"""Sensor platform for the Eve Online integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import EveOnlineConfigEntry, EveOnlineCoordinator, EveOnlineData +from .entity import EveOnlineCharacterEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class EveOnlineSensorDescription(SensorEntityDescription): + """Describe an Eve Online sensor.""" + + value_fn: Callable[[EveOnlineData], str | int | float | datetime | None] + + +CHARACTER_SENSORS: tuple[EveOnlineSensorDescription, ...] = ( + EveOnlineSensorDescription( + key="location", + translation_key="location", + value_fn=lambda data: ( + data.resolved_names.get( + data.location.solar_system_id, + str(data.location.solar_system_id), + ) + if data.location + else None + ), + ), + EveOnlineSensorDescription( + key="ship", + translation_key="ship", + value_fn=lambda data: ( + data.resolved_names.get(data.ship.ship_type_id, str(data.ship.ship_type_id)) + if data.ship + else None + ), + ), + EveOnlineSensorDescription( + key="wallet_balance", + translation_key="wallet_balance", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda data: ( + data.wallet_balance.balance if data.wallet_balance else None + ), + ), + EveOnlineSensorDescription( + key="total_sp", + translation_key="total_sp", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.skills.total_sp if data.skills else None, + ), + EveOnlineSensorDescription( + key="unallocated_sp", + translation_key="unallocated_sp", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + value_fn=lambda data: data.skills.unallocated_sp if data.skills else None, + ), + EveOnlineSensorDescription( + key="skill_queue_count", + translation_key="skill_queue_count", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: len(data.skill_queue), + ), + EveOnlineSensorDescription( + key="current_training_skill", + translation_key="current_training_skill", + value_fn=lambda data: ( + data.resolved_names.get( + data.skill_queue[0].skill_id, + str(data.skill_queue[0].skill_id), + ) + + f" {data.skill_queue[0].finished_level}" + if data.skill_queue + else None + ), + ), + EveOnlineSensorDescription( + key="current_skill_finish", + translation_key="current_skill_finish", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: ( + data.skill_queue[0].finish_date + if data.skill_queue and data.skill_queue[0].finish_date + else None + ), + ), + EveOnlineSensorDescription( + key="unread_mail", + translation_key="unread_mail", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: ( + data.mail_labels.total_unread_count if data.mail_labels else None + ), + ), + EveOnlineSensorDescription( + key="industry_jobs", + translation_key="industry_jobs", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: sum( + 1 for j in data.industry_jobs if j.status == "active" + ), + ), + EveOnlineSensorDescription( + key="next_industry_finish", + translation_key="next_industry_finish", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: min( + (j.end_date for j in data.industry_jobs if j.status == "active"), + default=None, + ), + ), + EveOnlineSensorDescription( + key="sell_orders", + translation_key="sell_orders", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: sum(1 for o in data.market_orders if not o.is_buy_order), + ), + EveOnlineSensorDescription( + key="buy_orders", + translation_key="buy_orders", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: sum(1 for o in data.market_orders if o.is_buy_order), + ), + EveOnlineSensorDescription( + key="jump_fatigue", + translation_key="jump_fatigue", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: ( + data.jump_fatigue.jump_fatigue_expire_date + if data.jump_fatigue and data.jump_fatigue.jump_fatigue_expire_date + else None + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EveOnlineConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Eve Online sensors from a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + EveOnlineCharacterSensor(coordinator, description) + for description in CHARACTER_SENSORS + ) + + +class EveOnlineSensor(SensorEntity): + """Base class for Eve Online sensors.""" + + entity_description: EveOnlineSensorDescription + coordinator: EveOnlineCoordinator + + @property + def native_value(self) -> str | int | float | datetime | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) + + +class EveOnlineCharacterSensor(EveOnlineCharacterEntity, EveOnlineSensor): + """Eve Online character sensor (per-character device).""" + + def __init__( + self, + coordinator: EveOnlineCoordinator, + description: EveOnlineSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, description.key) + self.entity_description = description diff --git a/homeassistant/components/eveonline/strings.json b/homeassistant/components/eveonline/strings.json new file mode 100644 index 00000000000000..fd34477e690ca7 --- /dev/null +++ b/homeassistant/components/eveonline/strings.json @@ -0,0 +1,87 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + } + }, + "entity": { + "sensor": { + "buy_orders": { + "name": "Buy orders", + "unit_of_measurement": "orders" + }, + "current_skill_finish": { + "name": "Current skill finish" + }, + "current_training_skill": { + "name": "Current training skill" + }, + "industry_jobs": { + "name": "Industry jobs", + "unit_of_measurement": "jobs" + }, + "jump_fatigue": { + "name": "Jump fatigue" + }, + "location": { + "name": "Location" + }, + "next_industry_finish": { + "name": "Next industry finish" + }, + "sell_orders": { + "name": "Sell orders", + "unit_of_measurement": "orders" + }, + "ship": { + "name": "Ship" + }, + "skill_queue_count": { + "name": "Skill queue", + "unit_of_measurement": "skills" + }, + "total_sp": { + "name": "Total skill points", + "unit_of_measurement": "SP" + }, + "unallocated_sp": { + "name": "Unallocated skill points", + "unit_of_measurement": "SP" + }, + "unread_mail": { + "name": "Unread mail", + "unit_of_measurement": "messages" + }, + "wallet_balance": { + "name": "Wallet balance", + "unit_of_measurement": "ISK" + } + } + }, + "exceptions": { + "authentication_failed": { + "message": "Authentication failed. Please reauthenticate." + }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, + "token_refresh_failed": { + "message": "Failed to refresh OAuth token: {error}" + }, + "update_failed": { + "message": "Error communicating with Eve Online API: {error}" + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a520338e91629c..8492f53fe02192 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -9,6 +9,7 @@ "dropbox", "ekeybionyx", "electric_kiwi", + "eveonline", "fitbit", "gentex_homelink", "geocaching", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b28eb0a3c74e36..1459f03677aff9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -206,6 +206,7 @@ "esphome", "essent", "eufylife_ble", + "eveonline", "evil_genius_labs", "ezviz", "faa_delays", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a13d5b35294a18..8c2049a9f2c487 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1925,6 +1925,12 @@ "matter" ] }, + "eveonline": { + "name": "Eve Online", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "evergy": { "name": "Evergy", "integration_type": "virtual", diff --git a/mypy.ini b/mypy.ini index 0ca25a2f94ba2b..cd3b451459ad9e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1745,6 +1745,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.eveonline.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.evil_genius_labs.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 6fd469be6bb62c..00a067d0bdc41c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2577,6 +2577,9 @@ python-ecobee-api==0.3.2 # homeassistant.components.etherscan python-etherscan-api==0.0.3 +# homeassistant.components.eveonline +python-eveonline==0.4.0 + # homeassistant.components.familyhub python-family-hub-local==0.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f988ff2d13cff..88fe18d59afd0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2194,6 +2194,9 @@ python-dropbox-api==0.1.3 # homeassistant.components.ecobee python-ecobee-api==0.3.2 +# homeassistant.components.eveonline +python-eveonline==0.4.0 + # homeassistant.components.fully_kiosk python-fullykiosk==0.0.15 diff --git a/tests/components/eveonline/__init__.py b/tests/components/eveonline/__init__.py new file mode 100644 index 00000000000000..9ca9f680f86beb --- /dev/null +++ b/tests/components/eveonline/__init__.py @@ -0,0 +1 @@ +"""Tests for the Eve Online integration.""" diff --git a/tests/components/eveonline/conftest.py b/tests/components/eveonline/conftest.py new file mode 100644 index 00000000000000..b76547d295e7ee --- /dev/null +++ b/tests/components/eveonline/conftest.py @@ -0,0 +1,100 @@ +"""Fixtures for the Eve Online integration tests.""" + +from collections.abc import Generator +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.eveonline.const import ( + CONF_CHARACTER_ID, + CONF_CHARACTER_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "test-client-id" +CLIENT_SECRET = "test-client-secret" +CHARACTER_ID = 12345678 +CHARACTER_NAME = "Test Capsuleer" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to set up application credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=CHARACTER_NAME, + unique_id=str(CHARACTER_ID), + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 1200, + "expires_at": time.time() + 1200, + "token_type": "Bearer", + }, + CONF_CHARACTER_ID: CHARACTER_ID, + CONF_CHARACTER_NAME: CHARACTER_NAME, + }, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_eveonline_client() -> Generator[AsyncMock]: + """Mock the EveOnlineClient.""" + with patch( + "homeassistant.components.eveonline.EveOnlineClient", + autospec=True, + ) as mock_client_class: + client = mock_client_class.return_value + client.async_get_character_online.return_value = None + client.async_get_wallet_balance.return_value = None + client.async_get_skill_queue.return_value = [] + client.async_get_character_location.return_value = None + client.async_get_character_ship.return_value = None + client.async_get_skills.return_value = None + client.async_get_mail_labels.return_value = None + client.async_get_industry_jobs.return_value = [] + client.async_get_market_orders.return_value = [] + client.async_get_jump_fatigue.return_value = None + client.async_resolve_names.return_value = [] + yield client + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> MockConfigEntry: + """Set up the Eve Online integration.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + return mock_config_entry diff --git a/tests/components/eveonline/snapshots/test_sensor.ambr b/tests/components/eveonline/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..a31853fed892eb --- /dev/null +++ b/tests/components/eveonline/snapshots/test_sensor.ambr @@ -0,0 +1,739 @@ +# serializer version: 1 +# name: test_sensor_entity_state[sensor.test_capsuleer_buy_orders-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_buy_orders', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Buy orders', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Buy orders', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'buy_orders', + 'unique_id': '12345678_buy_orders', + 'unit_of_measurement': 'orders', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_buy_orders-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Buy orders', + 'state_class': , + 'unit_of_measurement': 'orders', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_buy_orders', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_current_skill_finish-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_current_skill_finish', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current skill finish', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current skill finish', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_skill_finish', + 'unique_id': '12345678_current_skill_finish', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_current_skill_finish-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Capsuleer Current skill finish', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_current_skill_finish', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_current_training_skill-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_current_training_skill', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Current training skill', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Current training skill', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_training_skill', + 'unique_id': '12345678_current_training_skill', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_current_training_skill-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Current training skill', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_current_training_skill', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_industry_jobs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_industry_jobs', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Industry jobs', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Industry jobs', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'industry_jobs', + 'unique_id': '12345678_industry_jobs', + 'unit_of_measurement': 'jobs', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_industry_jobs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Industry jobs', + 'state_class': , + 'unit_of_measurement': 'jobs', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_industry_jobs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_jump_fatigue-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_jump_fatigue', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Jump fatigue', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Jump fatigue', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'jump_fatigue', + 'unique_id': '12345678_jump_fatigue', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_jump_fatigue-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Capsuleer Jump fatigue', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_jump_fatigue', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_location-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Location', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Location', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': '12345678_location', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_location-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Location', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_location', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_next_industry_finish-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_next_industry_finish', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Next industry finish', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Next industry finish', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'next_industry_finish', + 'unique_id': '12345678_next_industry_finish', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_next_industry_finish-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Capsuleer Next industry finish', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_next_industry_finish', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_sell_orders-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_sell_orders', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Sell orders', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Sell orders', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sell_orders', + 'unique_id': '12345678_sell_orders', + 'unit_of_measurement': 'orders', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_sell_orders-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Sell orders', + 'state_class': , + 'unit_of_measurement': 'orders', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_sell_orders', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_ship-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_ship', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Ship', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ship', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ship', + 'unique_id': '12345678_ship', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_ship-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Ship', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_ship', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_skill_queue-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_skill_queue', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Skill queue', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Skill queue', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'skill_queue_count', + 'unique_id': '12345678_skill_queue_count', + 'unit_of_measurement': 'skills', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_skill_queue-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Skill queue', + 'state_class': , + 'unit_of_measurement': 'skills', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_skill_queue', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_total_skill_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_total_skill_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Total skill points', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total skill points', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_sp', + 'unique_id': '12345678_total_sp', + 'unit_of_measurement': 'SP', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_total_skill_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Total skill points', + 'state_class': , + 'unit_of_measurement': 'SP', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_total_skill_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_unallocated_skill_points-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_unallocated_skill_points', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Unallocated skill points', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unallocated skill points', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'unallocated_sp', + 'unique_id': '12345678_unallocated_sp', + 'unit_of_measurement': 'SP', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_unallocated_skill_points-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Unallocated skill points', + 'state_class': , + 'unit_of_measurement': 'SP', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_unallocated_skill_points', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_unread_mail-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_unread_mail', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Unread mail', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Unread mail', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'unread_mail', + 'unique_id': '12345678_unread_mail', + 'unit_of_measurement': 'messages', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_unread_mail-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Unread mail', + 'state_class': , + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_unread_mail', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_wallet_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_capsuleer_wallet_balance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wallet balance', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wallet balance', + 'platform': 'eveonline', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wallet_balance', + 'unique_id': '12345678_wallet_balance', + 'unit_of_measurement': 'ISK', + }) +# --- +# name: test_sensor_entity_state[sensor.test_capsuleer_wallet_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Capsuleer Wallet balance', + 'state_class': , + 'unit_of_measurement': 'ISK', + }), + 'context': , + 'entity_id': 'sensor.test_capsuleer_wallet_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/eveonline/test_api.py b/tests/components/eveonline/test_api.py new file mode 100644 index 00000000000000..2cc210341b5c10 --- /dev/null +++ b/tests/components/eveonline/test_api.py @@ -0,0 +1,58 @@ +"""Test the Eve Online API authentication helper.""" + +from unittest.mock import AsyncMock, MagicMock, Mock + +import aiohttp +import pytest + +from homeassistant.components.eveonline.api import AsyncConfigEntryAuth +from homeassistant.components.eveonline.const import DOMAIN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestReauthError, + OAuth2TokenRequestTransientError, +) + + +def _make_auth( + token_valid_side_effect: Exception | None = None, +) -> AsyncConfigEntryAuth: + """Create an AsyncConfigEntryAuth with a mocked OAuth2Session.""" + websession = MagicMock(spec=aiohttp.ClientSession) + oauth_session = AsyncMock() + oauth_session.async_ensure_token_valid = AsyncMock( + side_effect=token_valid_side_effect + ) + oauth_session.token = {"access_token": "mock-token"} + return AsyncConfigEntryAuth(websession, oauth_session) + + +async def test_get_access_token_success() -> None: + """Test that a valid access token is returned.""" + auth = _make_auth() + token = await auth.async_get_access_token() + assert token == "mock-token" + + +async def test_get_access_token_reauth_error() -> None: + """Test that OAuth2TokenRequestReauthError raises ConfigEntryAuthFailed.""" + auth = _make_auth(OAuth2TokenRequestReauthError(domain=DOMAIN, request_info=Mock())) + with pytest.raises(ConfigEntryAuthFailed): + await auth.async_get_access_token() + + +async def test_get_access_token_transient_error() -> None: + """Test that OAuth2TokenRequestTransientError raises ConfigEntryNotReady.""" + auth = _make_auth( + OAuth2TokenRequestTransientError(domain=DOMAIN, request_info=Mock()) + ) + with pytest.raises(ConfigEntryNotReady): + await auth.async_get_access_token() + + +async def test_get_access_token_client_error() -> None: + """Test that aiohttp.ClientError raises ConfigEntryNotReady.""" + auth = _make_auth(aiohttp.ClientError("network")) + with pytest.raises(ConfigEntryNotReady): + await auth.async_get_access_token() diff --git a/tests/components/eveonline/test_config_flow.py b/tests/components/eveonline/test_config_flow.py new file mode 100644 index 00000000000000..1c706ccae43c90 --- /dev/null +++ b/tests/components/eveonline/test_config_flow.py @@ -0,0 +1,249 @@ +"""Test the Eve Online config flow.""" + +import base64 +import json +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.eveonline.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + SCOPES, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +CHARACTER_ID = 95465499 +CHARACTER_NAME = "CCP Bartender" + + +def _make_jwt(character_id: int, character_name: str) -> str: + """Create a fake Eve SSO JWT token.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=") + payload_data = { + "sub": f"CHARACTER:EVE:{character_id}", + "name": character_name, + } + payload = base64.urlsafe_b64encode(json.dumps(payload_data).encode()).rstrip(b"=") + signature = base64.urlsafe_b64encode(b"fakesig").rstrip(b"=") + return f"{header.decode()}.{payload.decode()}.{signature.decode()}" + + +def _make_jwt_with_sub(sub: str) -> str: + """Create a fake Eve SSO JWT with a custom subject for error testing.""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b"=") + payload = base64.urlsafe_b64encode( + json.dumps({"sub": sub, "name": "Test"}).encode() + ).rstrip(b"=") + sig = base64.urlsafe_b64encode(b"fakesig").rstrip(b"=") + return f"{header.decode()}.{payload.decode()}.{sig.decode()}" + + +async def _setup_credentials(hass: HomeAssistant) -> None: + """Set up application credentials.""" + assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full OAuth2 flow.""" + await _setup_credentials(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + scope = "+".join(SCOPES) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={scope}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + fake_jwt = _make_jwt(CHARACTER_ID, CHARACTER_NAME) + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + with patch( + "homeassistant.components.eveonline.async_setup_entry", + return_value=True, + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + entry = hass.config_entries.async_entries(DOMAIN)[0] + assert entry.unique_id == str(CHARACTER_ID) + assert entry.title == CHARACTER_NAME + assert entry.data["character_id"] == CHARACTER_ID + assert entry.data["character_name"] == CHARACTER_NAME + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_flow_rejects_duplicate_character( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check that adding the same character twice is rejected.""" + await _setup_credentials(hass) + + # First flow — should succeed. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + fake_jwt = _make_jwt(CHARACTER_ID, CHARACTER_NAME) + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + with patch( + "homeassistant.components.eveonline.async_setup_entry", + return_value=True, + ): + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + # Second flow — same character, should abort. + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state2 = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result2["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + await client.get(f"/auth/external/callback?code=abcd&state={state2}") + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token-2", + "access_token": fake_jwt, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + result2 = await hass.config_entries.flow.async_configure(result2["flow_id"]) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" + + +@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.parametrize( + "access_token", + [ + "not-a-jwt", + "header.!!!invalid-base64!!!.signature", + _make_jwt_with_sub("WRONG:FORMAT"), + _make_jwt_with_sub("CHARACTER:SERENITY:12345"), + _make_jwt_with_sub("CHARACTER:EVE:not-a-number"), + ], +) +async def test_flow_aborts_on_bad_jwt( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, +) -> None: + """Test that bad JWT tokens abort the config flow.""" + await _setup_credentials(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 1200, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "oauth_error" diff --git a/tests/components/eveonline/test_init.py b/tests/components/eveonline/test_init.py new file mode 100644 index 00000000000000..5a590c542e8570 --- /dev/null +++ b/tests/components/eveonline/test_init.py @@ -0,0 +1,259 @@ +"""Test the Eve Online integration setup.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import aiohttp +from eveonline import EveOnlineError +from eveonline.models import ( + CharacterLocation, + CharacterShip, + IndustryJob, + MarketOrder, + UniverseName, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) + +from tests.common import MockConfigEntry + + +async def test_setup_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test successful setup of a config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "exception", + [ + EveOnlineError("API unavailable"), + aiohttp.ClientError("Connection reset"), + ], +) +async def test_setup_entry_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, + exception: Exception, +) -> None: + """Test setup failure when an error occurs.""" + mock_eveonline_client.async_get_character_online.side_effect = exception + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, +) -> None: + """Test successful unloading of a config entry.""" + assert init_integration.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "exception", + [ + EveOnlineError("Endpoint down"), + aiohttp.ClientError("Connection lost"), + ], +) +async def test_coordinator_optional_endpoint_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, + exception: Exception, +) -> None: + """Test that errors on optional endpoints don't fail the coordinator. + + When an optional endpoint raises an error, the coordinator should still + load successfully with None/empty values for that data. + """ + mock_eveonline_client.async_get_wallet_balance.side_effect = exception + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("sensor.test_capsuleer_wallet_balance") + assert state is not None + assert state.state == "unknown" + + +async def test_coordinator_list_endpoint_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that errors on list endpoints return empty lists gracefully.""" + mock_eveonline_client.async_get_skill_queue.side_effect = EveOnlineError( + "Service unavailable" + ) + mock_eveonline_client.async_get_industry_jobs.side_effect = EveOnlineError( + "Service unavailable" + ) + mock_eveonline_client.async_get_market_orders.side_effect = EveOnlineError( + "Service unavailable" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # List endpoints return empty lists, count sensors should show 0 + state = hass.states.get("sensor.test_capsuleer_skill_queue") + assert state is not None + assert state.state == "0" + + # Industry jobs and market orders should also show 0 + state = hass.states.get("sensor.test_capsuleer_industry_jobs") + assert state is not None + assert state.state == "0" + + +async def test_setup_entry_implementation_unavailable( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test setup when OAuth implementation is unavailable.""" + with patch( + "homeassistant.components.eveonline.async_get_config_entry_implementation", + side_effect=ImplementationUnavailableError("not available"), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_list_endpoint_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that auth errors on list endpoints degrade gracefully.""" + mock_eveonline_client.async_get_skill_queue.side_effect = EveOnlineError( + "Token revoked" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("sensor.test_capsuleer_skill_queue") + assert state is not None + assert state.state == "0" + + +async def test_coordinator_resolves_names( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that the coordinator resolves IDs to names via the ESI API.""" + mock_eveonline_client.async_get_character_location.return_value = CharacterLocation( + solar_system_id=30000142 + ) + mock_eveonline_client.async_get_character_ship.return_value = CharacterShip( + ship_type_id=587, ship_item_id=1, ship_name="My Rifter" + ) + mock_eveonline_client.async_get_industry_jobs.return_value = [ + IndustryJob( + job_id=1, + activity_id=1, + status="active", + start_date=datetime(2026, 1, 1, tzinfo=UTC), + end_date=datetime(2026, 1, 2, tzinfo=UTC), + blueprint_type_id=1137, + output_location_id=60003760, + runs=1, + product_type_id=1138, + ), + ] + mock_eveonline_client.async_get_market_orders.return_value = [ + MarketOrder( + order_id=1, + type_id=34, + is_buy_order=False, + price=10.0, + volume_remain=100, + volume_total=100, + location_id=60003760, + region_id=10000002, + issued=datetime(2026, 1, 1, tzinfo=UTC), + duration=90, + range="region", + ), + ] + mock_eveonline_client.async_resolve_names.return_value = [ + UniverseName(id=30000142, name="Jita", category="solar_system"), + UniverseName(id=587, name="Rifter", category="inventory_type"), + UniverseName(id=1137, name="Blueprint", category="inventory_type"), + UniverseName(id=1138, name="Product", category="inventory_type"), + UniverseName(id=34, name="Tritanium", category="inventory_type"), + ] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Location should show resolved name + state = hass.states.get("sensor.test_capsuleer_location") + assert state is not None + assert state.state == "Jita" + + # Ship should show resolved name + state = hass.states.get("sensor.test_capsuleer_ship") + assert state is not None + assert state.state == "Rifter" + + +async def test_coordinator_resolve_names_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that a resolve_names failure degrades gracefully.""" + mock_eveonline_client.async_get_character_location.return_value = CharacterLocation( + solar_system_id=30000142 + ) + mock_eveonline_client.async_resolve_names.side_effect = EveOnlineError( + "Resolve failed" + ) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Location should fall back to numeric ID + state = hass.states.get("sensor.test_capsuleer_location") + assert state is not None + assert state.state == "30000142" diff --git a/tests/components/eveonline/test_sensor.py b/tests/components/eveonline/test_sensor.py new file mode 100644 index 00000000000000..9f6a91e97e33ab --- /dev/null +++ b/tests/components/eveonline/test_sensor.py @@ -0,0 +1,95 @@ +"""Test the Eve Online sensor platform.""" + +from unittest.mock import AsyncMock + +from eveonline.models import SkillQueueEntry, WalletBalance +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensor_entity_state( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, + init_integration: MockConfigEntry, +) -> None: + """Test that all sensor entities are created with the correct state.""" + for entity_entry in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ): + if entity_entry.disabled_by is not None: + entity_registry.async_update_entity( + entity_entry.entity_id, disabled_by=None + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("sensor_entity_id", "expected_state"), + [ + ("sensor.test_capsuleer_wallet_balance", "1234567.89"), + ("sensor.test_capsuleer_skill_queue", "2"), + ], +) +async def test_sensor_values( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, + sensor_entity_id: str, + expected_state: str, +) -> None: + """Test that sensors report correct values with specific return data.""" + mock_eveonline_client.async_get_wallet_balance.return_value = WalletBalance( + balance=1234567.89 + ) + mock_eveonline_client.async_get_skill_queue.return_value = [ + SkillQueueEntry( + skill_id=3436, + finished_level=5, + queue_position=0, + start_date=None, + finish_date=None, + ), + SkillQueueEntry( + skill_id=3437, + finished_level=4, + queue_position=1, + start_date=None, + finish_date=None, + ), + ] + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(sensor_entity_id) + assert state is not None + assert state.state == expected_state + + +async def test_unavailable_sensor( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_eveonline_client: AsyncMock, + setup_credentials: None, +) -> None: + """Test that sensors with no data show as unknown.""" + mock_eveonline_client.async_get_wallet_balance.return_value = None + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_capsuleer_wallet_balance") + assert state is not None + assert state.state == "unknown"