diff --git a/README.md b/README.md
index fad3b8f..6cfc365 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
[](https://github.com/Programmer-Timmy/ha-satisfactory/releases/latest) [](https://github.com/hacs/integration) [](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