diff --git a/README.md b/README.md index fad3b8f..6cfc365 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![version](https://img.shields.io/github/manifest-json/v/Programmer-Timmy/ha-satisfactory?filename=custom_components%2Fsatisfactory%2Fmanifest.json&color=slateblue)](https://github.com/Programmer-Timmy/ha-satisfactory/releases/latest) [![HACS](https://img.shields.io/badge/HACS-Default-orange.svg?logo=HomeAssistantCommunityStore&logoColor=white)](https://github.com/hacs/integration) [![gitlocalized ](https://gitlocalize.com/repo/10697/whole_project/badge.svg)](https://gitlocalize.com/repo/10697?utm_source=badge) - + # Ha-satisfactory @@ -44,6 +44,18 @@ Go to **Settings → Devices & Services → Add Integration** and search for **S | Total game duration | Total hours the game has been running | h | | Active session | Name of the active game session | — | | Game phase | Current game phase | — | +| Server health | Health status reported by the server | — | + +## Events + +The integration exposes a **Player activity** event entity that fires whenever a player joins or leaves the server (i.e., when the connected player count changes). You can use it in automations via the **State changed** trigger on the event entity. + +| Event type | Fired when | Event data | +|---|---|---| +| `satisfactory_player_joined` | A player connects to the server | `entry_id`, `num_connected_players`, `player_limit` | +| `satisfactory_player_left` | A player disconnects from the server | `entry_id`, `num_connected_players`, `player_limit` | + +> **Note:** Events are only fired on state *changes*. No event is fired on the very first data poll. The `entry_id` field identifies which server instance fired the event, which is useful if you have multiple servers configured. ## Removal diff --git a/custom_components/satisfactory/__init__.py b/custom_components/satisfactory/__init__.py index a00847d..5e54378 100644 --- a/custom_components/satisfactory/__init__.py +++ b/custom_components/satisfactory/__init__.py @@ -16,7 +16,7 @@ from .const import CONF_SKIP_SSL from .coordinator import SatisfactoryCoordinator -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] type SatisfactoryConfigEntry = ConfigEntry[SatisfactoryCoordinator] diff --git a/custom_components/satisfactory/event.py b/custom_components/satisfactory/event.py new file mode 100644 index 0000000..6838c21 --- /dev/null +++ b/custom_components/satisfactory/event.py @@ -0,0 +1,87 @@ +"""Event platform for the Satisfactory integration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.components.event import EventEntity +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SatisfactoryCoordinator + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddEntitiesCallback + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, # noqa: ARG001 + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Satisfactory event entities from a config entry.""" + coordinator: SatisfactoryCoordinator = entry.runtime_data + async_add_entities([SatisfactoryPlayerActivityEventEntity(coordinator, entry)]) + + +class SatisfactoryPlayerActivityEventEntity( + CoordinatorEntity[SatisfactoryCoordinator], EventEntity +): + """Event entity that fires when players join or leave the server.""" + + _attr_event_types: list[str] = ["player_joined", "player_left"] # noqa: RUF012 + _attr_translation_key = "player_activity" + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SatisfactoryCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize the event entity.""" + super().__init__(coordinator) + self._entry_id = entry.entry_id + self._attr_unique_id = f"{entry.unique_id}_player_activity" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.unique_id or entry.entry_id)}, + name=entry.title, + manufacturer="Coffee Stain Studios", + model="Satisfactory Dedicated Server", + configuration_url=entry.data.get("configuration_url") + if isinstance(entry.data, dict) + else None, + ) + self._prev_players: int | None = None + + @callback + def _handle_coordinator_update(self) -> None: + """Trigger a player event when the connected player count changes.""" + new_players: int = self.coordinator.data.get("numConnectedPlayers", 0) + player_limit: int = self.coordinator.data.get("playerLimit", 0) + + if self._prev_players is not None: + if new_players > self._prev_players: + self._trigger_event( + "player_joined", + { + "num_connected_players": new_players, + "player_limit": player_limit, + }, + ) + elif new_players < self._prev_players: + self._trigger_event( + "player_left", + { + "num_connected_players": new_players, + "player_limit": player_limit, + }, + ) + + self._prev_players = new_players + super()._handle_coordinator_update() diff --git a/custom_components/satisfactory/strings.json b/custom_components/satisfactory/strings.json index ed32f82..ffb631e 100644 --- a/custom_components/satisfactory/strings.json +++ b/custom_components/satisfactory/strings.json @@ -20,6 +20,19 @@ } }, "entity": { + "event": { + "player_activity": { + "name": "Player activity", + "state_attributes": { + "event_type": { + "state": { + "player_joined": "Player joined", + "player_left": "Player left" + } + } + } + } + }, "sensor": { "num_connected_players": {"name": "Connected players"}, "player_limit": {"name": "Player limit"}, diff --git a/custom_components/satisfactory/translations/en.json b/custom_components/satisfactory/translations/en.json index f14752f..a55a7ce 100644 --- a/custom_components/satisfactory/translations/en.json +++ b/custom_components/satisfactory/translations/en.json @@ -20,6 +20,19 @@ } }, "entity": { + "event": { + "player_activity": { + "name": "Player activity", + "state_attributes": { + "event_type": { + "state": { + "player_joined": "Player joined", + "player_left": "Player left" + } + } + } + } + }, "sensor": { "num_connected_players": {"name": "Connected players"}, "player_limit": {"name": "Player limit"}, diff --git a/tests/satisfactory/test_event.py b/tests/satisfactory/test_event.py new file mode 100644 index 0000000..4375675 --- /dev/null +++ b/tests/satisfactory/test_event.py @@ -0,0 +1,95 @@ +"""Tests for the Satisfactory event platform.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from custom_components.satisfactory.event import SatisfactoryPlayerActivityEventEntity + + +@pytest.fixture +def mock_coordinator() -> MagicMock: + """Return a mock coordinator with sample data.""" + coordinator = MagicMock() + coordinator.data = {"numConnectedPlayers": 2, "playerLimit": 4} + return coordinator + + +@pytest.fixture +def entity( + mock_coordinator: MagicMock, +) -> SatisfactoryPlayerActivityEventEntity: + """Return a SatisfactoryPlayerActivityEventEntity with mocked dependencies.""" + with patch("homeassistant.helpers.update_coordinator.CoordinatorEntity.__init__"): + ent = SatisfactoryPlayerActivityEventEntity.__new__( + SatisfactoryPlayerActivityEventEntity + ) + ent.coordinator = mock_coordinator + ent._prev_players = None # noqa: SLF001 + ent.async_write_ha_state = MagicMock() + return ent + + +class TestSatisfactoryPlayerActivityEventEntity: + """Tests for SatisfactoryPlayerActivityEventEntity.""" + + def test_event_types(self, entity: SatisfactoryPlayerActivityEventEntity) -> None: + assert entity.event_types == ["player_joined", "player_left"] + + def test_player_joined_fires_event( + self, + entity: SatisfactoryPlayerActivityEventEntity, + mock_coordinator: MagicMock, + ) -> None: + entity._prev_players = 1 # noqa: SLF001 + mock_coordinator.data = {"numConnectedPlayers": 2, "playerLimit": 4} + with patch.object(entity, "_trigger_event") as mock_trigger: + entity._handle_coordinator_update() # noqa: SLF001 + mock_trigger.assert_called_once_with( + "player_joined", + {"num_connected_players": 2, "player_limit": 4}, + ) + + def test_player_left_fires_event( + self, + entity: SatisfactoryPlayerActivityEventEntity, + mock_coordinator: MagicMock, + ) -> None: + entity._prev_players = 3 # noqa: SLF001 + mock_coordinator.data = {"numConnectedPlayers": 2, "playerLimit": 4} + with patch.object(entity, "_trigger_event") as mock_trigger: + entity._handle_coordinator_update() # noqa: SLF001 + mock_trigger.assert_called_once_with( + "player_left", + {"num_connected_players": 2, "player_limit": 4}, + ) + + def test_no_event_on_first_update( + self, entity: SatisfactoryPlayerActivityEventEntity + ) -> None: + entity._prev_players = None # noqa: SLF001 + with patch.object(entity, "_trigger_event") as mock_trigger: + entity._handle_coordinator_update() # noqa: SLF001 + mock_trigger.assert_not_called() + + def test_no_event_when_count_unchanged( + self, + entity: SatisfactoryPlayerActivityEventEntity, + mock_coordinator: MagicMock, + ) -> None: + entity._prev_players = 2 # noqa: SLF001 + mock_coordinator.data = {"numConnectedPlayers": 2, "playerLimit": 4} + with patch.object(entity, "_trigger_event") as mock_trigger: + entity._handle_coordinator_update() # noqa: SLF001 + mock_trigger.assert_not_called() + + def test_prev_players_updated_after_call( + self, + entity: SatisfactoryPlayerActivityEventEntity, + mock_coordinator: MagicMock, + ) -> None: + entity._prev_players = 1 # noqa: SLF001 + mock_coordinator.data = {"numConnectedPlayers": 3, "playerLimit": 4} + with patch.object(entity, "_trigger_event"): + entity._handle_coordinator_update() # noqa: SLF001 + assert entity._prev_players == 3 # noqa: SLF001