1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Remove Tractive sensors that are no longer supported by the Tractive API (#160089)

This commit is contained in:
Maciej Bieniek
2026-01-24 09:34:53 +01:00
committed by GitHub
parent 3facfa3359
commit 103097b74f
8 changed files with 40 additions and 295 deletions

View File

@@ -5,10 +5,11 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
from typing import Any, cast
from typing import Any
import aiotractive
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_BATTERY_CHARGING,
@@ -20,21 +21,20 @@ from homeassistant.const import (
)
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
ATTR_ACTIVITY_LABEL,
ATTR_CALORIES,
ATTR_DAILY_GOAL,
ATTR_MINUTES_ACTIVE,
ATTR_MINUTES_DAY_SLEEP,
ATTR_MINUTES_NIGHT_SLEEP,
ATTR_MINUTES_REST,
ATTR_POWER_SAVING,
ATTR_SLEEP_LABEL,
ATTR_TRACKER_STATE,
CLIENT_ID,
DOMAIN,
RECONNECT_INTERVAL,
SERVER_UNAVAILABLE,
SWITCH_KEY_MAP,
@@ -42,7 +42,6 @@ from .const import (
TRACKER_HEALTH_OVERVIEW_UPDATED,
TRACKER_POSITION_UPDATED,
TRACKER_SWITCH_STATUS_UPDATED,
TRACKER_WELLNESS_STATUS_UPDATED,
)
PLATFORMS = [
@@ -129,6 +128,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) ->
)
entry.async_on_unload(tractive.unsubscribe)
# Remove sensor entities that are no longer supported by the Tractive API
entity_reg = er.async_get(hass)
for item in filtered_trackables:
for key in ("activity_label", "calories", "sleep_label"):
if entity_id := entity_reg.async_get_entity_id(
SENSOR_PLATFORM, DOMAIN, f"{item.trackable['_id']}_{key}"
):
entity_reg.async_remove(entity_id)
return True
@@ -207,19 +215,6 @@ class TractiveClient:
return not self._listen_task.cancelled()
async def trackable_objects(
self,
) -> list[aiotractive.trackable_object.TrackableObject]:
"""Get list of trackable objects."""
return cast(
list[aiotractive.trackable_object.TrackableObject],
await self._client.trackable_objects(),
)
def tracker(self, tracker_id: str) -> aiotractive.tracker.Tracker:
"""Get tracker by id."""
return self._client.tracker(tracker_id)
def subscribe(self) -> None:
"""Start event listener coroutine."""
self._listen_task = asyncio.create_task(self._listen())
@@ -242,9 +237,6 @@ class TractiveClient:
if event["message"] == "health_overview":
self.send_health_overview_update(event)
continue
if event["message"] == "wellness_overview":
self._send_wellness_update(event)
continue
if (
"hardware" in event
and self._last_hw_time != event["hardware"]["time"]
@@ -312,26 +304,6 @@ class TractiveClient:
TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload
)
def _send_wellness_update(self, event: dict[str, Any]) -> None:
sleep_day = None
sleep_night = None
if isinstance(event["sleep"], dict):
sleep_day = event["sleep"]["minutes_day_sleep"]
sleep_night = event["sleep"]["minutes_night_sleep"]
payload = {
ATTR_ACTIVITY_LABEL: event["wellness"].get("activity_label"),
ATTR_CALORIES: event["activity"]["calories"],
ATTR_DAILY_GOAL: event["activity"]["minutes_goal"],
ATTR_MINUTES_ACTIVE: event["activity"]["minutes_active"],
ATTR_MINUTES_DAY_SLEEP: sleep_day,
ATTR_MINUTES_NIGHT_SLEEP: sleep_night,
ATTR_MINUTES_REST: event["activity"]["minutes_rest"],
ATTR_SLEEP_LABEL: event["wellness"].get("sleep_label"),
}
self._dispatch_tracker_event(
TRACKER_WELLNESS_STATUS_UPDATED, event["pet_id"], payload
)
def send_health_overview_update(self, event: dict[str, Any]) -> None:
"""Handle health_overview events from Tractive API."""
# The health_overview response can be at root level or wrapped in 'content'

View File

@@ -6,9 +6,7 @@ DOMAIN = "tractive"
RECONNECT_INTERVAL = timedelta(seconds=10)
ATTR_ACTIVITY_LABEL = "activity_label"
ATTR_BUZZER = "buzzer"
ATTR_CALORIES = "calories"
ATTR_DAILY_GOAL = "daily_goal"
ATTR_LED = "led"
ATTR_LIVE_TRACKING = "live_tracking"
@@ -17,7 +15,6 @@ ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep"
ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep"
ATTR_MINUTES_REST = "minutes_rest"
ATTR_POWER_SAVING = "power_saving"
ATTR_SLEEP_LABEL = "sleep_label"
ATTR_TRACKER_STATE = "tracker_state"
# This client ID was issued by Tractive specifically for Home Assistant.
@@ -27,7 +24,6 @@ CLIENT_ID = "625e5349c3c3b41c28a669f1"
TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated"
TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated"
TRACKER_SWITCH_STATUS_UPDATED = f"{DOMAIN}_tracker_switch_updated"
TRACKER_WELLNESS_STATUS_UPDATED = f"{DOMAIN}_tracker_wellness_updated"
TRACKER_HEALTH_OVERVIEW_UPDATED = f"{DOMAIN}_tracker_health_overview_updated"
SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable"

View File

@@ -16,7 +16,6 @@ from homeassistant.const import (
ATTR_BATTERY_LEVEL,
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
@@ -25,18 +24,14 @@ from homeassistant.helpers.typing import StateType
from . import Trackables, TractiveClient, TractiveConfigEntry
from .const import (
ATTR_ACTIVITY_LABEL,
ATTR_CALORIES,
ATTR_DAILY_GOAL,
ATTR_MINUTES_ACTIVE,
ATTR_MINUTES_DAY_SLEEP,
ATTR_MINUTES_NIGHT_SLEEP,
ATTR_MINUTES_REST,
ATTR_SLEEP_LABEL,
ATTR_TRACKER_STATE,
TRACKER_HARDWARE_STATUS_UPDATED,
TRACKER_HEALTH_OVERVIEW_UPDATED,
TRACKER_WELLNESS_STATUS_UPDATED,
)
from .entity import TractiveEntity
@@ -126,13 +121,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
state_class=SensorStateClass.TOTAL,
),
TractiveSensorEntityDescription(
key=ATTR_CALORIES,
translation_key="calories",
native_unit_of_measurement=UnitOfEnergy.KILO_CALORIE,
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
state_class=SensorStateClass.TOTAL,
),
TractiveSensorEntityDescription(
key=ATTR_DAILY_GOAL,
translation_key="daily_goal",
@@ -153,30 +141,6 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
state_class=SensorStateClass.TOTAL,
),
TractiveSensorEntityDescription(
key=ATTR_SLEEP_LABEL,
translation_key="sleep",
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
value_fn=lambda state: state.lower() if isinstance(state, str) else state,
device_class=SensorDeviceClass.ENUM,
options=[
"good",
"low",
"ok",
],
),
TractiveSensorEntityDescription(
key=ATTR_ACTIVITY_LABEL,
translation_key="activity",
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
value_fn=lambda state: state.lower() if isinstance(state, str) else state,
device_class=SensorDeviceClass.ENUM,
options=[
"good",
"low",
"ok",
],
),
)

View File

@@ -33,20 +33,9 @@
}
},
"sensor": {
"activity": {
"name": "Activity",
"state": {
"good": "Good",
"low": "Low",
"ok": "OK"
}
},
"activity_time": {
"name": "Activity time"
},
"calories": {
"name": "Calories burned"
},
"daily_goal": {
"name": "Daily goal"
},
@@ -59,14 +48,6 @@
"rest_time": {
"name": "Rest time"
},
"sleep": {
"name": "Sleep",
"state": {
"good": "[%key:component::tractive::entity::sensor::activity::state::good%]",
"low": "[%key:component::tractive::entity::sensor::activity::state::low%]",
"ok": "[%key:component::tractive::entity::sensor::activity::state::ok%]"
}
},
"tracker_battery_level": {
"name": "Tracker battery"
},

View File

@@ -34,24 +34,6 @@ def mock_tractive_client() -> Generator[AsyncMock]:
}
entry.runtime_data.client._send_hardware_update(event)
def send_wellness_event(
entry: MockConfigEntry, event: dict[str, Any] | None = None
):
"""Send wellness event."""
if event is None:
event = {
"pet_id": "pet_id_123",
"sleep": {"minutes_day_sleep": 100, "minutes_night_sleep": 300},
"wellness": {"activity_label": "ok", "sleep_label": "good"},
"activity": {
"calories": 999,
"minutes_goal": 200,
"minutes_active": 150,
"minutes_rest": 122,
},
}
entry.runtime_data.client._send_wellness_update(event)
def send_health_overview_event(
entry: MockConfigEntry, event: dict[str, Any] | None = None
):
@@ -144,7 +126,6 @@ def mock_tractive_client() -> Generator[AsyncMock]:
)
client.send_hardware_event = send_hardware_event
client.send_wellness_event = send_wellness_event
client.send_health_overview_event = send_health_overview_event
client.send_position_event = send_position_event
client.send_switch_event = send_switch_event

View File

@@ -1,65 +1,4 @@
# serializer version: 1
# name: test_sensor[sensor.test_pet_activity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'good',
'low',
'ok',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_pet_activity',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Activity',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Activity',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'activity',
'unique_id': 'pet_id_123_activity_label',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.test_pet_activity-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Test Pet Activity',
'options': list([
'good',
'low',
'ok',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.test_pet_activity',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'ok',
})
# ---
# name: test_sensor[sensor.test_pet_activity_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -113,59 +52,6 @@
'state': '150',
})
# ---
# name: test_sensor[sensor.test_pet_calories_burned-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_pet_calories_burned',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Calories burned',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Calories burned',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'calories',
'unique_id': 'pet_id_123_calories',
'unit_of_measurement': <UnitOfEnergy.KILO_CALORIE: 'kcal'>,
})
# ---
# name: test_sensor[sensor.test_pet_calories_burned-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Pet Calories burned',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfEnergy.KILO_CALORIE: 'kcal'>,
}),
'context': <ANY>,
'entity_id': 'sensor.test_pet_calories_burned',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '999',
})
# ---
# name: test_sensor[sensor.test_pet_daily_goal-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@@ -375,67 +261,6 @@
'state': '122',
})
# ---
# name: test_sensor[sensor.test_pet_sleep-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'good',
'low',
'ok',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.test_pet_sleep',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Sleep',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Sleep',
'platform': 'tractive',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'sleep',
'unique_id': 'pet_id_123_sleep_label',
'unit_of_measurement': None,
})
# ---
# name: test_sensor[sensor.test_pet_sleep-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Test Pet Sleep',
'options': list([
'good',
'low',
'ok',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.test_pet_sleep',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'good',
})
# ---
# name: test_sensor[sensor.test_pet_tracker_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, patch
from aiotractive.exceptions import TractiveError, UnauthorizedError
import pytest
from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.components.tractive.const import (
ATTR_DAILY_GOAL,
ATTR_MINUTES_ACTIVE,
@@ -17,6 +18,7 @@ from homeassistant.components.tractive.const import (
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import init_integration
@@ -216,3 +218,28 @@ async def test_missing_activity_data(
payload = async_dispatcher_send_mock.mock_calls[0][1][2]
assert payload[ATTR_DAILY_GOAL] is None
assert payload[ATTR_MINUTES_ACTIVE] is None
@pytest.mark.parametrize("sensor", ["activity_label", "calories", "sleep_label"])
async def test_remove_unsupported_sensor_entity(
hass: HomeAssistant,
mock_tractive_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
sensor: str,
) -> None:
"""Test removing unsupported sensor entity."""
entity_id = f"sensor.test_pet_{sensor}"
mock_config_entry.add_to_hass(hass)
entity_registry.async_get_or_create(
SENSOR_PLATFORM,
DOMAIN,
f"pet_id_123_{sensor}",
suggested_object_id=entity_id.rsplit(".", maxsplit=1)[-1],
config_entry=mock_config_entry,
)
await init_integration(hass, mock_config_entry)
assert entity_registry.async_get(entity_id) is None

View File

@@ -25,7 +25,6 @@ async def test_sensor(
await init_integration(hass, mock_config_entry)
mock_tractive_client.send_hardware_event(mock_config_entry)
mock_tractive_client.send_wellness_event(mock_config_entry)
mock_tractive_client.send_health_overview_event(mock_config_entry)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)