Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ce2dc73
Add Eve Online integration with sensor platform
ronaldvdmeer Apr 13, 2026
c611e21
Simplify Eve Online integration to Bronze quality level
ronaldvdmeer Apr 13, 2026
f9b156e
Address Copilot review: fix test timing and add PR links to quality s…
ronaldvdmeer Apr 13, 2026
18a2f87
Fix ship sensor to show ship type name instead of player-assigned name
ronaldvdmeer Apr 13, 2026
e057b84
Mark config-entry-unloading as done in quality scale
ronaldvdmeer Apr 13, 2026
1e52861
Fix quality_scale: mark integration-owner, entity-unavailable, log-wh…
ronaldvdmeer Apr 14, 2026
dc6b02a
Address Copilot review: rename test functions to reflect unknown state
ronaldvdmeer Apr 14, 2026
fdb811a
Address erwindouna review: parallel location+ship fetch, Implementati…
ronaldvdmeer Apr 14, 2026
58e326a
Remove test_api.py and move auth failure test to test_init.py
ronaldvdmeer Apr 14, 2026
e8e3d4d
Remove single-use intermediate variables in async_setup_entry
ronaldvdmeer Apr 15, 2026
1350694
Remove single-use variables and docstring in async_oauth_create_entry
ronaldvdmeer Apr 15, 2026
9eafa74
Remove unrealistic 'Unknown' fallback for character name in JWT decode
ronaldvdmeer Apr 15, 2026
7d12819
Replace SCAN_INTERVAL constant with inline timedelta(minutes=1)
ronaldvdmeer Apr 15, 2026
379bee8
Remove helper coroutines in favour of direct asyncio.gather with shar…
ronaldvdmeer Apr 15, 2026
c669260
Merge branch 'dev' into feat/eveonline-core-sensors
ronaldvdmeer Apr 16, 2026
c4764db
Address erwindouna review: move type alias to top, add slots/kw_only …
ronaldvdmeer Apr 16, 2026
0dd5fe5
Fix asyncio.gather: use return_exceptions=True to preserve independen…
ronaldvdmeer Apr 16, 2026
1c93ddc
Fix mypy: use BaseException instead of Exception for return_exception…
ronaldvdmeer Apr 16, 2026
fa3ef85
Replace asyncio.gather+isinstance with sequential try/except per opti…
ronaldvdmeer Apr 16, 2026
017a002
Address Copilot review: use character-specific coordinator name, catc…
ronaldvdmeer Apr 16, 2026
ddfb001
Fix ruff formatting: single line function call
ronaldvdmeer Apr 16, 2026
b34e169
Merge branch 'dev' into feat/eveonline-core-sensors
erwindouna Apr 17, 2026
c9a84db
Add TimeoutError to optional endpoint except clauses in coordinator
ronaldvdmeer Apr 17, 2026
6271516
Use result variable consistently in multi-flow config flow tests
ronaldvdmeer Apr 17, 2026
fb26507
Use STATE_UNAVAILABLE constant in sensor test
ronaldvdmeer Apr 17, 2026
0209c66
Use STATE_UNKNOWN constant in sensor tests
ronaldvdmeer Apr 17, 2026
c5bea38
Add model and configuration_url to DeviceInfo
ronaldvdmeer Apr 19, 2026
8bc0bb0
Merge branch 'dev' into feat/eveonline-core-sensors
ronaldvdmeer Apr 19, 2026
d99bbbb
Fix quality_scale: mark test-before-configure as exempt
ronaldvdmeer Apr 20, 2026
bc00048
Merge branch 'dev' into feat/eveonline-core-sensors
ronaldvdmeer Apr 26, 2026
e5226ac
Merge branch 'dev' into feat/eveonline-core-sensors
ronaldvdmeer May 2, 2026
50e32ce
Add Eve Online reauth flow
ronaldvdmeer May 2, 2026
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
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions homeassistant/components/eveonline/__init__.py
Original file line number Diff line number Diff line change
@@ -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
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(
"OAuth2 implementation unavailable, check application credentials"
) from err
auth = AsyncConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass),
OAuth2Session(hass, entry, implementation),
)
client = EveOnlineClient(auth=auth)

coordinator = EveOnlineCoordinator(
hass,
entry,
client,
entry.data[CONF_CHARACTER_ID],
entry.data[CONF_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)
41 changes: 41 additions & 0 deletions homeassistant/components/eveonline/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""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,
OAuth2TokenRequestReauthError,
OAuth2TokenRequestTransientError,
)
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import UpdateFailed


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(
"Authentication failed, please reauthenticate"
) from err
except (OAuth2TokenRequestTransientError, ClientError) as err:
raise UpdateFailed(f"Failed to refresh OAuth token: {err}") from err
return cast(str, self._oauth_session.token["access_token"])
14 changes: 14 additions & 0 deletions homeassistant/components/eveonline/application_credentials.py
Original file line number Diff line number Diff line change
@@ -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,
)
99 changes: 99 additions & 0 deletions homeassistant/components/eveonline/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Config flow for the Eve Online integration."""

from __future__ import annotations

from collections.abc import Mapping
import logging
from typing import Any

import jwt

from homeassistant.config_entries import SOURCE_REAUTH, 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_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()

async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is None:
reauth_entry = self._get_reauth_entry()
return self.async_show_form(
step_id="reauth_confirm",
description_placeholders={
"character": reauth_entry.data[CONF_CHARACTER_NAME]
},
)

return await self.async_step_user()

async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow."""
try:
token = data["token"]["access_token"]
character_info = _decode_eve_jwt(token)
except ValueError, KeyError, jwt.DecodeError:
return self.async_abort(reason="oauth_error")

data[CONF_CHARACTER_ID] = character_info[CONF_CHARACTER_ID]
data[CONF_CHARACTER_NAME] = character_info[CONF_CHARACTER_NAME]

await self.async_set_unique_id(str(character_info[CONF_CHARACTER_ID]))

if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
return self.async_update_reload_and_abort(
reauth_entry,
title=character_info[CONF_CHARACTER_NAME],
data=data,
)

self._abort_if_unique_id_configured()

return self.async_create_entry(
title=character_info[CONF_CHARACTER_NAME],
data=data,
)
Comment thread
ronaldvdmeer marked this conversation as resolved.


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)
Comment thread
ronaldvdmeer marked this conversation as resolved.
return {
CONF_CHARACTER_ID: int(sub_parts[2]),
CONF_CHARACTER_NAME: decoded["name"],
}
17 changes: 17 additions & 0 deletions homeassistant/components/eveonline/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""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-location.read_location.v1",
"esi-location.read_ship_type.v1",
"esi-wallet.read_character_wallet.v1",
]
108 changes: 108 additions & 0 deletions homeassistant/components/eveonline/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Coordinator for the Eve Online integration."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import timedelta
import logging

import aiohttp
from eveonline import EveOnlineClient, EveOnlineError
from eveonline.models import CharacterLocation, CharacterShip

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__)

Comment thread
ronaldvdmeer marked this conversation as resolved.
type EveOnlineConfigEntry = ConfigEntry[EveOnlineCoordinator]


@dataclass(slots=True, kw_only=True)
class EveOnlineData:
"""Eve Online character data."""

character_id: int
character_name: str
Comment thread
ronaldvdmeer marked this conversation as resolved.
wallet_balance: float | None = None
location: CharacterLocation | None = None
solar_system_name: str | None = None
ship: CharacterShip | None = None
ship_type_name: str | None = None


class EveOnlineCoordinator(DataUpdateCoordinator[EveOnlineData]):
"""Coordinator for Eve Online character data."""

def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
client: EveOnlineClient,
character_id: int,
character_name: str,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN} {character_name} ({character_id})",
update_interval=timedelta(minutes=1),
)
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:
wallet = await self.client.async_get_wallet_balance(self.character_id)
except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err:
raise UpdateFailed(
f"Error communicating with Eve Online API: {err}"
) from err

location: CharacterLocation | None = None
ship: CharacterShip | None = None
try:
location = await self.client.async_get_character_location(self.character_id)
except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err:
_LOGGER.debug("Failed to fetch location: %s", err)
try:
ship = await self.client.async_get_character_ship(self.character_id)
except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err:
_LOGGER.debug("Failed to fetch ship: %s", err)

solar_system_name: str | None = None
ship_type_name: str | None = None

ids_to_resolve = []
if location:
ids_to_resolve.append(location.solar_system_id)
if ship:
ids_to_resolve.append(ship.ship_type_id)

if ids_to_resolve:
try:
resolved = await self.client.async_resolve_names(ids_to_resolve)
Comment thread
ronaldvdmeer marked this conversation as resolved.
resolved_by_id = {r.id: r.name for r in resolved}
if location:
solar_system_name = resolved_by_id.get(location.solar_system_id)
if ship:
ship_type_name = resolved_by_id.get(ship.ship_type_id)
except (EveOnlineError, aiohttp.ClientError, TimeoutError) as err:
_LOGGER.debug("Failed to resolve names: %s", err)

return EveOnlineData(
character_id=self.character_id,
character_name=self.character_name,
wallet_balance=wallet.balance,
location=location,
solar_system_name=solar_system_name,
ship=ship,
ship_type_name=ship_type_name,
)
28 changes: 28 additions & 0 deletions homeassistant/components/eveonline/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Base entity 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 EveOnlineCharacterEntity(CoordinatorEntity[EveOnlineCoordinator]):
"""Base entity for an Eve Online character."""

_attr_has_entity_name = True

def __init__(self, coordinator: EveOnlineCoordinator, key: str) -> None:
Comment thread
ronaldvdmeer marked this conversation as resolved.
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.character_id}_{key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, str(coordinator.character_id))},
manufacturer="CCP Games",
model="Character",
name=coordinator.character_name,
configuration_url=f"https://evewho.com/character/{coordinator.character_id}",
)
13 changes: 13 additions & 0 deletions homeassistant/components/eveonline/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
Loading
Loading