Skip to content

Commit 874ff3b

Browse files
committed
Add Ituran integration
1 parent 4776865 commit 874ff3b

18 files changed

+1002
-0
lines changed

CODEOWNERS

+2
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,8 @@ build.json @home-assistant/supervisor
753753
/tests/components/ista_ecotrend/ @tr4nt0r
754754
/homeassistant/components/isy994/ @bdraco @shbatm
755755
/tests/components/isy994/ @bdraco @shbatm
756+
/homeassistant/components/ituran/ @shmuelzon
757+
/tests/components/ituran/ @shmuelzon
756758
/homeassistant/components/izone/ @Swamp-Ig
757759
/tests/components/izone/ @Swamp-Ig
758760
/homeassistant/components/jellyfin/ @j-stienstra @ctalkington
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""The Ituran integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.config_entries import ConfigEntry
6+
from homeassistant.const import Platform
7+
from homeassistant.core import HomeAssistant
8+
9+
from .coordinator import IturanDataUpdateCoordinator
10+
11+
PLATFORMS: list[Platform] = [
12+
Platform.DEVICE_TRACKER,
13+
]
14+
15+
type IturanConfigEntry = ConfigEntry[IturanDataUpdateCoordinator]
16+
17+
18+
async def async_setup_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool:
19+
"""Set up Ituran from a config entry."""
20+
21+
coordinator = IturanDataUpdateCoordinator(hass, entry=entry)
22+
await coordinator.async_config_entry_first_refresh()
23+
entry.runtime_data = coordinator
24+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
25+
26+
return True
27+
28+
29+
async def async_unload_entry(hass: HomeAssistant, entry: IturanConfigEntry) -> bool:
30+
"""Unload a config entry."""
31+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Config flow for Ituran integration."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Mapping
6+
import logging
7+
from typing import Any
8+
9+
from pyituran import Ituran
10+
from pyituran.exceptions import IturanApiError, IturanAuthError
11+
import voluptuous as vol
12+
13+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
14+
15+
from .const import (
16+
CONF_ID_OR_PASSPORT,
17+
CONF_MOBILE_ID,
18+
CONF_OTP,
19+
CONF_PHONE_NUMBER,
20+
DOMAIN,
21+
)
22+
23+
_LOGGER = logging.getLogger(__name__)
24+
25+
STEP_USER_DATA_SCHEMA = vol.Schema(
26+
{
27+
vol.Required(CONF_ID_OR_PASSPORT): str,
28+
vol.Required(CONF_PHONE_NUMBER): str,
29+
vol.Optional(CONF_MOBILE_ID): str,
30+
}
31+
)
32+
33+
STEP_OTP_DATA_SCHEMA = vol.Schema(
34+
{
35+
vol.Required(CONF_OTP, description="OTP"): str,
36+
}
37+
)
38+
39+
40+
class IturanConfigFlow(ConfigFlow, domain=DOMAIN):
41+
"""Handle a config flow for Ituran."""
42+
43+
_user_info: dict[str, Any]
44+
45+
async def async_step_user(
46+
self, user_input: dict[str, Any] | None = None
47+
) -> ConfigFlowResult:
48+
"""Handle the inial step."""
49+
errors: dict[str, str] = {}
50+
if user_input is not None:
51+
if self.unique_id is None:
52+
await self.async_set_unique_id(user_input[CONF_ID_OR_PASSPORT])
53+
self._abort_if_unique_id_configured()
54+
55+
try:
56+
ituran = Ituran(
57+
user_input[CONF_ID_OR_PASSPORT],
58+
user_input[CONF_PHONE_NUMBER],
59+
user_input.get(CONF_MOBILE_ID),
60+
)
61+
user_input[CONF_MOBILE_ID] = ituran.mobile_id
62+
authenticated = await ituran.is_authenticated()
63+
if not authenticated:
64+
await ituran.request_otp()
65+
except IturanApiError:
66+
errors["base"] = "cannot_connect"
67+
except IturanAuthError:
68+
errors["base"] = "invalid_auth"
69+
except Exception:
70+
_LOGGER.exception("Unexpected exception")
71+
errors["base"] = "unknown"
72+
else:
73+
if authenticated:
74+
return self.async_create_entry(
75+
title=f"Ituran {user_input[CONF_ID_OR_PASSPORT]}",
76+
data=user_input,
77+
)
78+
self._user_info = user_input
79+
return await self.async_step_otp()
80+
81+
return self.async_show_form(
82+
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
83+
)
84+
85+
async def async_step_reauth(
86+
self, entry_data: Mapping[str, Any]
87+
) -> ConfigFlowResult:
88+
"""Handle configuration by re-auth."""
89+
return await self.async_step_user()
90+
91+
async def async_step_otp(
92+
self, user_input: dict[str, Any] | None = None
93+
) -> ConfigFlowResult:
94+
"""Handle the inial step."""
95+
errors: dict[str, str] = {}
96+
if user_input is not None:
97+
try:
98+
ituran = Ituran(
99+
self._user_info[CONF_ID_OR_PASSPORT],
100+
self._user_info[CONF_PHONE_NUMBER],
101+
self._user_info[CONF_MOBILE_ID],
102+
)
103+
await ituran.authenticate(user_input[CONF_OTP])
104+
except IturanApiError:
105+
errors["base"] = "cannot_connect"
106+
except IturanAuthError:
107+
errors["base"] = "invalid_otp"
108+
except Exception:
109+
_LOGGER.exception("Unexpected exception")
110+
errors["base"] = "unknown"
111+
else:
112+
return self.async_create_entry(
113+
title=f"Ituran {self._user_info[CONF_ID_OR_PASSPORT]}",
114+
data=self._user_info,
115+
)
116+
117+
return self.async_show_form(
118+
step_id="otp", data_schema=STEP_OTP_DATA_SCHEMA, errors=errors
119+
)
+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Constants for the Ituran integration."""
2+
3+
from typing import Final
4+
5+
DOMAIN = "ituran"
6+
7+
ATTR_HEADING: Final = "heading"
8+
ATTR_ADDRESS: Final = "address"
9+
ATTR_LAST_UPDATE: Final = "last_update"
10+
11+
CONF_ID_OR_PASSPORT: Final = "id_or_passport"
12+
CONF_PHONE_NUMBER: Final = "phone_number"
13+
CONF_MOBILE_ID: Final = "mobile_id"
14+
CONF_OTP: Final = "otp"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Coordinator for Ituran."""
2+
3+
from datetime import timedelta
4+
import logging
5+
6+
from pyituran import Ituran, Vehicle
7+
from pyituran.exceptions import IturanApiError, IturanAuthError
8+
9+
from homeassistant.config_entries import ConfigEntry
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.exceptions import ConfigEntryAuthFailed
12+
from homeassistant.helpers import device_registry as dr
13+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
14+
15+
from .const import CONF_ID_OR_PASSPORT, CONF_MOBILE_ID, CONF_PHONE_NUMBER, DOMAIN
16+
17+
_LOGGER = logging.getLogger(__name__)
18+
19+
20+
class IturanDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]):
21+
"""Class to manage fetching Ituran data."""
22+
23+
ituran: Ituran
24+
25+
def __init__(self, hass: HomeAssistant, *, entry: ConfigEntry) -> None:
26+
"""Initialize account-wide BMW data updater."""
27+
self.ituran = Ituran(
28+
entry.data[CONF_ID_OR_PASSPORT],
29+
entry.data[CONF_PHONE_NUMBER],
30+
entry.data[CONF_MOBILE_ID],
31+
)
32+
self._entry = entry
33+
34+
super().__init__(
35+
hass,
36+
_LOGGER,
37+
name=f"{DOMAIN}-{entry.data[CONF_ID_OR_PASSPORT]}",
38+
update_interval=timedelta(seconds=300),
39+
config_entry=entry,
40+
)
41+
42+
async def _async_update_data(self) -> dict[str, Vehicle]:
43+
"""Fetch data from Ituran."""
44+
45+
try:
46+
vehicles = await self.ituran.get_vehicles()
47+
except IturanApiError as e:
48+
raise UpdateFailed(e) from e
49+
except IturanAuthError as e:
50+
raise ConfigEntryAuthFailed(e) from e
51+
52+
updated_data = {vehicle.license_plate: vehicle for vehicle in vehicles}
53+
self._cleanup_removed_vehicles(updated_data)
54+
55+
return updated_data
56+
57+
def _cleanup_removed_vehicles(self, data: dict[str, Vehicle]) -> None:
58+
account_vehicles = {(DOMAIN, license_plate) for license_plate in data}
59+
device_registry = dr.async_get(self.hass)
60+
device_entries = dr.async_entries_for_config_entry(
61+
device_registry, config_entry_id=self._entry.entry_id
62+
)
63+
for device in device_entries:
64+
if not device.identifiers.intersection(account_vehicles):
65+
device_registry.async_update_device(
66+
device.id, remove_config_entry_id=self._entry.entry_id
67+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Device tracker for Ituran vehicles."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from homeassistant.components.device_tracker import TrackerEntity
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
10+
11+
from . import IturanConfigEntry
12+
from .const import ATTR_ADDRESS, ATTR_HEADING, ATTR_LAST_UPDATE
13+
from .coordinator import IturanDataUpdateCoordinator
14+
from .entity import IturanBaseEntity
15+
16+
17+
async def async_setup_entry(
18+
hass: HomeAssistant,
19+
config_entry: IturanConfigEntry,
20+
async_add_entities: AddEntitiesCallback,
21+
) -> None:
22+
"""Set up the Ituran tracker from config entry."""
23+
coordinator = config_entry.runtime_data
24+
async_add_entities(
25+
IturanDeviceTracker(coordinator, license_plate)
26+
for license_plate in coordinator.data
27+
)
28+
29+
30+
class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
31+
"""Ituran device tracker."""
32+
33+
_attr_force_update = False
34+
_attr_translation_key = "car"
35+
_attr_name = None
36+
37+
def __init__(
38+
self,
39+
coordinator: IturanDataUpdateCoordinator,
40+
license_plate: str,
41+
) -> None:
42+
"""Initialize the device tracker."""
43+
super().__init__(coordinator, license_plate, "device_tracker")
44+
45+
@property
46+
def extra_state_attributes(self) -> dict[str, Any]:
47+
"""Return entity specific state attributes."""
48+
return {
49+
ATTR_ADDRESS: self.vehicle.address,
50+
ATTR_HEADING: self.vehicle.heading,
51+
ATTR_LAST_UPDATE: self.vehicle.last_update,
52+
}
53+
54+
@property
55+
def latitude(self) -> float | None:
56+
"""Return latitude value of the device."""
57+
return self.vehicle.gps_coordinates[0]
58+
59+
@property
60+
def longitude(self) -> float | None:
61+
"""Return longitude value of the device."""
62+
return self.vehicle.gps_coordinates[1]
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Base for all turan entities."""
2+
3+
from __future__ import annotations
4+
5+
from pyituran import Vehicle
6+
7+
from homeassistant.helpers.device_registry import DeviceInfo
8+
from homeassistant.helpers.update_coordinator import CoordinatorEntity
9+
10+
from .const import DOMAIN
11+
from .coordinator import IturanDataUpdateCoordinator
12+
13+
14+
class IturanBaseEntity(CoordinatorEntity[IturanDataUpdateCoordinator]):
15+
"""Common base for Ituran entities."""
16+
17+
_attr_has_entity_name = True
18+
19+
def __init__(
20+
self,
21+
coordinator: IturanDataUpdateCoordinator,
22+
license_plate: str,
23+
unique_key: str,
24+
) -> None:
25+
"""Initialize the entity."""
26+
super().__init__(coordinator)
27+
28+
self._license_plate = license_plate
29+
self._attr_unique_id = f"{license_plate}-{unique_key}"
30+
31+
self._attr_device_info = DeviceInfo(
32+
identifiers={(DOMAIN, self.vehicle.license_plate)},
33+
manufacturer=self.vehicle.make,
34+
model=self.vehicle.model,
35+
name=self.vehicle.model,
36+
serial_number=self.vehicle.license_plate,
37+
)
38+
39+
@property
40+
def available(self) -> bool:
41+
"""Return True if vehicle is still included in the account."""
42+
return self._license_plate in self.coordinator.data
43+
44+
@property
45+
def vehicle(self) -> Vehicle:
46+
"""Return the vehicle information associated with this entity."""
47+
return self.coordinator.data[self._license_plate]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"entity": {
3+
"device_tracker": {
4+
"car": {
5+
"default": "mdi:car"
6+
}
7+
}
8+
}
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"domain": "ituran",
3+
"name": "Ituran",
4+
"codeowners": ["@shmuelzon"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/ituran",
7+
"integration_type": "hub",
8+
"iot_class": "cloud_polling",
9+
"requirements": ["pyituran==0.1.2"]
10+
}

0 commit comments

Comments
 (0)