From 37f6ada40826e7a7da7dd999b0ede7fd593f6f91 Mon Sep 17 00:00:00 2001 From: Programmer-Timmy Date: Tue, 3 Mar 2026 22:30:19 +0100 Subject: [PATCH 1/8] Add player join/leave events to Satisfactory integration This update introduces events for when players join or leave the Satisfactory server. The events are fired on state changes, allowing users to create automations based on player activity. Additionally, the SatisfactoryCoordinator now accepts an entry_id to identify server instances. --- README.md | 14 ++- custom_components/satisfactory/__init__.py | 2 +- custom_components/satisfactory/const.py | 3 + custom_components/satisfactory/coordinator.py | 37 ++++++- custom_components/satisfactory/strings.json | 10 ++ .../satisfactory/translations/en.json | 10 ++ tests/satisfactory/test_coordinator.py | 98 +++++++++++++++++++ 7 files changed, 170 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6331c91..89cd450 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![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 fires events on the Home Assistant event bus whenever the server state changes. You can use these in automations via the **Event** trigger with the event type listed below. + +| 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..118a1d2 100644 --- a/custom_components/satisfactory/__init__.py +++ b/custom_components/satisfactory/__init__.py @@ -38,7 +38,7 @@ async def async_setup_entry( except APIError as err: raise ConfigEntryAuthFailed from err - coordinator = SatisfactoryCoordinator(hass, client) + coordinator = SatisfactoryCoordinator(hass, client, entry.entry_id) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/custom_components/satisfactory/const.py b/custom_components/satisfactory/const.py index 3a40c57..da91924 100644 --- a/custom_components/satisfactory/const.py +++ b/custom_components/satisfactory/const.py @@ -6,3 +6,6 @@ DEFAULT_PORT = 7777 DEFAULT_SCAN_INTERVAL = 30 CONF_SKIP_SSL = "skip_ssl_verification" + +EVENT_PLAYER_JOINED = f"{DOMAIN}_player_joined" +EVENT_PLAYER_LEFT = f"{DOMAIN}_player_left" diff --git a/custom_components/satisfactory/coordinator.py b/custom_components/satisfactory/coordinator.py index ab78778..4be3a63 100644 --- a/custom_components/satisfactory/coordinator.py +++ b/custom_components/satisfactory/coordinator.py @@ -10,7 +10,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from satisfactory_api_client.exceptions import APIError -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + EVENT_PLAYER_JOINED, + EVENT_PLAYER_LEFT, +) if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -22,7 +27,9 @@ class SatisfactoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator to poll the Satisfactory Dedicated Server API.""" - def __init__(self, hass: HomeAssistant, client: AsyncSatisfactoryAPI) -> None: + def __init__( + self, hass: HomeAssistant, client: AsyncSatisfactoryAPI, entry_id: str + ) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -31,6 +38,7 @@ def __init__(self, hass: HomeAssistant, client: AsyncSatisfactoryAPI) -> None: update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self.client = client + self.entry_id = entry_id def _sanitise_game_phase(self, game_phase: str) -> str: """Convert the game phase string from the API into a more user-friendly format.""" # noqa: E501 @@ -67,6 +75,29 @@ def _sanitise_data(self, data: dict[str, Any]) -> dict[str, Any]: "gamePhase": self._sanitise_game_phase(data.get("gamePhase", "")), } + def _fire_events(self, old_data: dict[str, Any], new_data: dict[str, Any]) -> None: + """Fire HA events for notable state changes.""" + old_players = old_data.get("numConnectedPlayers", 0) + new_players = new_data.get("numConnectedPlayers", 0) + if new_players > old_players: + self.hass.bus.async_fire( + EVENT_PLAYER_JOINED, + { + "entry_id": self.entry_id, + "num_connected_players": new_players, + "player_limit": new_data.get("playerLimit", 0), + }, + ) + elif new_players < old_players: + self.hass.bus.async_fire( + EVENT_PLAYER_LEFT, + { + "entry_id": self.entry_id, + "num_connected_players": new_players, + "player_limit": new_data.get("playerLimit", 0), + }, + ) + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from the Satisfactory server.""" try: @@ -82,4 +113,6 @@ async def _async_update_data(self) -> dict[str, Any]: data = self._sanitise_data(state_response.data.get("serverGameState", {})) data["serverHealth"] = health_response.data.get("health", "unknown") + if self.data: + self._fire_events(self.data, data) return data diff --git a/custom_components/satisfactory/strings.json b/custom_components/satisfactory/strings.json index ed32f82..ac8e928 100644 --- a/custom_components/satisfactory/strings.json +++ b/custom_components/satisfactory/strings.json @@ -30,5 +30,15 @@ "game_phase": {"name": "Game phase"}, "server_health": {"name": "Server health"} } + }, + "events": { + "satisfactory_player_joined": { + "name": "Player joined", + "description": "Fired when a player connects to the Satisfactory server." + }, + "satisfactory_player_left": { + "name": "Player left", + "description": "Fired when a player disconnects from the Satisfactory server." + } } } diff --git a/custom_components/satisfactory/translations/en.json b/custom_components/satisfactory/translations/en.json index f14752f..30e7c3b 100644 --- a/custom_components/satisfactory/translations/en.json +++ b/custom_components/satisfactory/translations/en.json @@ -30,5 +30,15 @@ "game_phase": {"name": "Game phase"}, "server_health": {"name": "Server health"} } + }, + "events": { + "satisfactory_player_joined": { + "name": "Player joined", + "description": "Fired when a player connects to the Satisfactory server." + }, + "satisfactory_player_left": { + "name": "Player left", + "description": "Fired when a player disconnects from the Satisfactory server." + } } } \ No newline at end of file diff --git a/tests/satisfactory/test_coordinator.py b/tests/satisfactory/test_coordinator.py index 57f1661..3d3014a 100644 --- a/tests/satisfactory/test_coordinator.py +++ b/tests/satisfactory/test_coordinator.py @@ -7,6 +7,10 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from satisfactory_api_client.exceptions import APIError +from custom_components.satisfactory.const import ( + EVENT_PLAYER_JOINED, + EVENT_PLAYER_LEFT, +) from custom_components.satisfactory.coordinator import SatisfactoryCoordinator @@ -35,6 +39,7 @@ def coordinator( coord.data = {} coord.logger = MagicMock() coord.hass = mock_hass + coord.entry_id = "test_entry_id" return coord @@ -216,3 +221,96 @@ async def test_empty_server_game_state( result = await coordinator._async_update_data() # noqa: SLF001 assert result["numConnectedPlayers"] == 0 assert result["serverHealth"] == "healthy" + + +# --- _fire_events --- + + +class TestFireEvents: + """Tests for _fire_events.""" + + def test_player_joined_fires_event( + self, coordinator: SatisfactoryCoordinator + ) -> None: + old = {"numConnectedPlayers": 1, "playerLimit": 4} + new = {"numConnectedPlayers": 2, "playerLimit": 4} + coordinator._fire_events(old, new) # noqa: SLF001 + coordinator.hass.bus.async_fire.assert_called_once_with( + EVENT_PLAYER_JOINED, + { + "entry_id": "test_entry_id", + "num_connected_players": 2, + "player_limit": 4, + }, + ) + + def test_player_left_fires_event( + self, coordinator: SatisfactoryCoordinator + ) -> None: + old = {"numConnectedPlayers": 3, "playerLimit": 4} + new = {"numConnectedPlayers": 2, "playerLimit": 4} + coordinator._fire_events(old, new) # noqa: SLF001 + coordinator.hass.bus.async_fire.assert_called_once_with( + EVENT_PLAYER_LEFT, + { + "entry_id": "test_entry_id", + "num_connected_players": 2, + "player_limit": 4, + }, + ) + + def test_no_player_event_when_count_unchanged( + self, coordinator: SatisfactoryCoordinator + ) -> None: + old = {"numConnectedPlayers": 2, "playerLimit": 4} + new = {"numConnectedPlayers": 2, "playerLimit": 4} + coordinator._fire_events(old, new) # noqa: SLF001 + coordinator.hass.bus.async_fire.assert_not_called() + + async def test_fire_events_called_on_update_when_data_exists( + self, coordinator: SatisfactoryCoordinator, mock_client: AsyncMock + ) -> None: + coordinator.data = { + "numConnectedPlayers": 1, + "playerLimit": 4, + "gamePhase": "Phase One", + "serverHealth": "healthy", + } + mock_state = MagicMock() + mock_state.data = { + "serverGameState": { + "numConnectedPlayers": 2, + "playerLimit": 4, + "gamePhase": "Foo/Bar.Phase_One", + } + } + mock_health = MagicMock() + mock_health.data = {"health": "healthy"} + mock_client.query_server_state.return_value = mock_state + mock_client.health_check.return_value = mock_health + + await coordinator._async_update_data() # noqa: SLF001 + + coordinator.hass.bus.async_fire.assert_called_once_with( + EVENT_PLAYER_JOINED, + { + "entry_id": "test_entry_id", + "num_connected_players": 2, + "player_limit": 4, + }, + ) + + async def test_no_events_on_first_update( + self, coordinator: SatisfactoryCoordinator, mock_client: AsyncMock + ) -> None: + coordinator.data = {} + mock_state = MagicMock() + mock_state.data = {"serverGameState": {"numConnectedPlayers": 2}} + mock_health = MagicMock() + mock_health.data = {"health": "healthy"} + mock_client.query_server_state.return_value = mock_state + mock_client.health_check.return_value = mock_health + + await coordinator._async_update_data() # noqa: SLF001 + + coordinator.hass.bus.async_fire.assert_not_called() From 401c7dc47798e781a0c1ef269ca9e839d73c6997 Mon Sep 17 00:00:00 2001 From: Programmer-Timmy Date: Tue, 3 Mar 2026 22:47:36 +0100 Subject: [PATCH 2/8] Add event handling for player activity in Satisfactory This update introduces a new event platform for tracking player join and leave events in the Satisfactory integration. It modifies the coordinator to manage player activity and updates the configuration to support these events, enhancing the overall functionality of the integration. --- custom_components/satisfactory/__init__.py | 4 +- custom_components/satisfactory/const.py | 3 - custom_components/satisfactory/coordinator.py | 37 +------ custom_components/satisfactory/event.py | 80 +++++++++++++++ custom_components/satisfactory/strings.json | 23 +++-- .../satisfactory/translations/en.json | 23 +++-- tests/satisfactory/test_coordinator.py | 98 ------------------- tests/satisfactory/test_event.py | 95 ++++++++++++++++++ 8 files changed, 205 insertions(+), 158 deletions(-) create mode 100644 custom_components/satisfactory/event.py create mode 100644 tests/satisfactory/test_event.py diff --git a/custom_components/satisfactory/__init__.py b/custom_components/satisfactory/__init__.py index 118a1d2..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] @@ -38,7 +38,7 @@ async def async_setup_entry( except APIError as err: raise ConfigEntryAuthFailed from err - coordinator = SatisfactoryCoordinator(hass, client, entry.entry_id) + coordinator = SatisfactoryCoordinator(hass, client) await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator diff --git a/custom_components/satisfactory/const.py b/custom_components/satisfactory/const.py index da91924..3a40c57 100644 --- a/custom_components/satisfactory/const.py +++ b/custom_components/satisfactory/const.py @@ -6,6 +6,3 @@ DEFAULT_PORT = 7777 DEFAULT_SCAN_INTERVAL = 30 CONF_SKIP_SSL = "skip_ssl_verification" - -EVENT_PLAYER_JOINED = f"{DOMAIN}_player_joined" -EVENT_PLAYER_LEFT = f"{DOMAIN}_player_left" diff --git a/custom_components/satisfactory/coordinator.py b/custom_components/satisfactory/coordinator.py index 4be3a63..ab78778 100644 --- a/custom_components/satisfactory/coordinator.py +++ b/custom_components/satisfactory/coordinator.py @@ -10,12 +10,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from satisfactory_api_client.exceptions import APIError -from .const import ( - DEFAULT_SCAN_INTERVAL, - DOMAIN, - EVENT_PLAYER_JOINED, - EVENT_PLAYER_LEFT, -) +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -27,9 +22,7 @@ class SatisfactoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator to poll the Satisfactory Dedicated Server API.""" - def __init__( - self, hass: HomeAssistant, client: AsyncSatisfactoryAPI, entry_id: str - ) -> None: + def __init__(self, hass: HomeAssistant, client: AsyncSatisfactoryAPI) -> None: """Initialize the coordinator.""" super().__init__( hass, @@ -38,7 +31,6 @@ def __init__( update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self.client = client - self.entry_id = entry_id def _sanitise_game_phase(self, game_phase: str) -> str: """Convert the game phase string from the API into a more user-friendly format.""" # noqa: E501 @@ -75,29 +67,6 @@ def _sanitise_data(self, data: dict[str, Any]) -> dict[str, Any]: "gamePhase": self._sanitise_game_phase(data.get("gamePhase", "")), } - def _fire_events(self, old_data: dict[str, Any], new_data: dict[str, Any]) -> None: - """Fire HA events for notable state changes.""" - old_players = old_data.get("numConnectedPlayers", 0) - new_players = new_data.get("numConnectedPlayers", 0) - if new_players > old_players: - self.hass.bus.async_fire( - EVENT_PLAYER_JOINED, - { - "entry_id": self.entry_id, - "num_connected_players": new_players, - "player_limit": new_data.get("playerLimit", 0), - }, - ) - elif new_players < old_players: - self.hass.bus.async_fire( - EVENT_PLAYER_LEFT, - { - "entry_id": self.entry_id, - "num_connected_players": new_players, - "player_limit": new_data.get("playerLimit", 0), - }, - ) - async def _async_update_data(self) -> dict[str, Any]: """Fetch data from the Satisfactory server.""" try: @@ -113,6 +82,4 @@ async def _async_update_data(self) -> dict[str, Any]: data = self._sanitise_data(state_response.data.get("serverGameState", {})) data["serverHealth"] = health_response.data.get("health", "unknown") - if self.data: - self._fire_events(self.data, data) return data diff --git a/custom_components/satisfactory/event.py b/custom_components/satisfactory/event.py new file mode 100644 index 0000000..383f16d --- /dev/null +++ b/custom_components/satisfactory/event.py @@ -0,0 +1,80 @@ +"""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._attr_unique_id = f"{entry.unique_id}_player_activity" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry.unique_id or entry.entry_id)}, + ) + 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 ac8e928..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"}, @@ -30,15 +43,5 @@ "game_phase": {"name": "Game phase"}, "server_health": {"name": "Server health"} } - }, - "events": { - "satisfactory_player_joined": { - "name": "Player joined", - "description": "Fired when a player connects to the Satisfactory server." - }, - "satisfactory_player_left": { - "name": "Player left", - "description": "Fired when a player disconnects from the Satisfactory server." - } } } diff --git a/custom_components/satisfactory/translations/en.json b/custom_components/satisfactory/translations/en.json index 30e7c3b..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"}, @@ -30,15 +43,5 @@ "game_phase": {"name": "Game phase"}, "server_health": {"name": "Server health"} } - }, - "events": { - "satisfactory_player_joined": { - "name": "Player joined", - "description": "Fired when a player connects to the Satisfactory server." - }, - "satisfactory_player_left": { - "name": "Player left", - "description": "Fired when a player disconnects from the Satisfactory server." - } } } \ No newline at end of file diff --git a/tests/satisfactory/test_coordinator.py b/tests/satisfactory/test_coordinator.py index 3d3014a..57f1661 100644 --- a/tests/satisfactory/test_coordinator.py +++ b/tests/satisfactory/test_coordinator.py @@ -7,10 +7,6 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from satisfactory_api_client.exceptions import APIError -from custom_components.satisfactory.const import ( - EVENT_PLAYER_JOINED, - EVENT_PLAYER_LEFT, -) from custom_components.satisfactory.coordinator import SatisfactoryCoordinator @@ -39,7 +35,6 @@ def coordinator( coord.data = {} coord.logger = MagicMock() coord.hass = mock_hass - coord.entry_id = "test_entry_id" return coord @@ -221,96 +216,3 @@ async def test_empty_server_game_state( result = await coordinator._async_update_data() # noqa: SLF001 assert result["numConnectedPlayers"] == 0 assert result["serverHealth"] == "healthy" - - -# --- _fire_events --- - - -class TestFireEvents: - """Tests for _fire_events.""" - - def test_player_joined_fires_event( - self, coordinator: SatisfactoryCoordinator - ) -> None: - old = {"numConnectedPlayers": 1, "playerLimit": 4} - new = {"numConnectedPlayers": 2, "playerLimit": 4} - coordinator._fire_events(old, new) # noqa: SLF001 - coordinator.hass.bus.async_fire.assert_called_once_with( - EVENT_PLAYER_JOINED, - { - "entry_id": "test_entry_id", - "num_connected_players": 2, - "player_limit": 4, - }, - ) - - def test_player_left_fires_event( - self, coordinator: SatisfactoryCoordinator - ) -> None: - old = {"numConnectedPlayers": 3, "playerLimit": 4} - new = {"numConnectedPlayers": 2, "playerLimit": 4} - coordinator._fire_events(old, new) # noqa: SLF001 - coordinator.hass.bus.async_fire.assert_called_once_with( - EVENT_PLAYER_LEFT, - { - "entry_id": "test_entry_id", - "num_connected_players": 2, - "player_limit": 4, - }, - ) - - def test_no_player_event_when_count_unchanged( - self, coordinator: SatisfactoryCoordinator - ) -> None: - old = {"numConnectedPlayers": 2, "playerLimit": 4} - new = {"numConnectedPlayers": 2, "playerLimit": 4} - coordinator._fire_events(old, new) # noqa: SLF001 - coordinator.hass.bus.async_fire.assert_not_called() - - async def test_fire_events_called_on_update_when_data_exists( - self, coordinator: SatisfactoryCoordinator, mock_client: AsyncMock - ) -> None: - coordinator.data = { - "numConnectedPlayers": 1, - "playerLimit": 4, - "gamePhase": "Phase One", - "serverHealth": "healthy", - } - mock_state = MagicMock() - mock_state.data = { - "serverGameState": { - "numConnectedPlayers": 2, - "playerLimit": 4, - "gamePhase": "Foo/Bar.Phase_One", - } - } - mock_health = MagicMock() - mock_health.data = {"health": "healthy"} - mock_client.query_server_state.return_value = mock_state - mock_client.health_check.return_value = mock_health - - await coordinator._async_update_data() # noqa: SLF001 - - coordinator.hass.bus.async_fire.assert_called_once_with( - EVENT_PLAYER_JOINED, - { - "entry_id": "test_entry_id", - "num_connected_players": 2, - "player_limit": 4, - }, - ) - - async def test_no_events_on_first_update( - self, coordinator: SatisfactoryCoordinator, mock_client: AsyncMock - ) -> None: - coordinator.data = {} - mock_state = MagicMock() - mock_state.data = {"serverGameState": {"numConnectedPlayers": 2}} - mock_health = MagicMock() - mock_health.data = {"health": "healthy"} - mock_client.query_server_state.return_value = mock_state - mock_client.health_check.return_value = mock_health - - await coordinator._async_update_data() # noqa: SLF001 - - coordinator.hass.bus.async_fire.assert_not_called() 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 From 45cf8e689807da5839dc54f70be9d333fca11c3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:03:34 +0000 Subject: [PATCH 3/8] Initial plan From 6db80b56269b42f3b3ce0268691e89ca6e7182d7 Mon Sep 17 00:00:00 2001 From: Programmer-Timmy <122127801+Programmer-Timmy@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:03:44 +0100 Subject: [PATCH 4/8] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- custom_components/satisfactory/event.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7048693..63c39de 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Go to **Settings → Devices & Services → Add Integration** and search for **S ## Events -The integration fires events on the Home Assistant event bus whenever the server state changes. You can use these in automations via the **Event** trigger with the event type listed below. +The integration fires events on the Home Assistant event bus when players join or leave the server (that is, when the number of connected players changes). You can use these in automations via the **Event** trigger with the event type listed below. | Event type | Fired when | Event data | |---|---|---| diff --git a/custom_components/satisfactory/event.py b/custom_components/satisfactory/event.py index 383f16d..9ec9939 100644 --- a/custom_components/satisfactory/event.py +++ b/custom_components/satisfactory/event.py @@ -46,9 +46,15 @@ def __init__( ) -> None: """Initialize the event entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{entry.unique_id}_player_activity" + self._attr_unique_id = f"{(entry.unique_id or entry.entry_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 From c42eddd7ec6f77ff5872386d2fc91edc5121834e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:05:39 +0000 Subject: [PATCH 5/8] Add entry_id to player activity event data for multi-server disambiguation Co-authored-by: Programmer-Timmy <122127801+Programmer-Timmy@users.noreply.github.com> --- custom_components/satisfactory/event.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/custom_components/satisfactory/event.py b/custom_components/satisfactory/event.py index 383f16d..84b3d65 100644 --- a/custom_components/satisfactory/event.py +++ b/custom_components/satisfactory/event.py @@ -46,6 +46,7 @@ def __init__( ) -> 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)}, @@ -63,6 +64,7 @@ def _handle_coordinator_update(self) -> None: self._trigger_event( "player_joined", { + "entry_id": self._entry_id, "num_connected_players": new_players, "player_limit": player_limit, }, @@ -71,6 +73,7 @@ def _handle_coordinator_update(self) -> None: self._trigger_event( "player_left", { + "entry_id": self._entry_id, "num_connected_players": new_players, "player_limit": player_limit, }, From 53fd9a7f4c6d098ada6557293be0fdb6d2d8e127 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:10:14 +0000 Subject: [PATCH 6/8] Update docs and Dutch translations; verify ruff passes Co-authored-by: Programmer-Timmy <122127801+Programmer-Timmy@users.noreply.github.com> --- README.md | 2 +- .../satisfactory/translations/nl.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7048693..6cfc365 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Go to **Settings → Devices & Services → Add Integration** and search for **S ## Events -The integration fires events on the Home Assistant event bus whenever the server state changes. You can use these in automations via the **Event** trigger with the event type listed below. +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 | |---|---|---| diff --git a/custom_components/satisfactory/translations/nl.json b/custom_components/satisfactory/translations/nl.json index 2d14c3d..635076f 100644 --- a/custom_components/satisfactory/translations/nl.json +++ b/custom_components/satisfactory/translations/nl.json @@ -20,6 +20,19 @@ } }, "entity": { + "event": { + "player_activity": { + "name": "Speler activity", + "state_attributes": { + "event_type": { + "state": { + "player_joined": "Speler verbonden", + "player_left": "Speler verbroken" + } + } + } + } + }, "sensor": { "num_connected_players": { "name": "Verbonden spelers" @@ -41,6 +54,9 @@ }, "game_phase": { "name": "Spelfase" + }, + "server_health": { + "name": "Servergezondheid" } } } From 60cdc20007a3b5ffcec1dedbcf996427c0fe3c07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:10:28 +0000 Subject: [PATCH 7/8] Revert Dutch translation changes (handled by gitlocalize) Co-authored-by: Programmer-Timmy <122127801+Programmer-Timmy@users.noreply.github.com> --- .../satisfactory/translations/nl.json | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/custom_components/satisfactory/translations/nl.json b/custom_components/satisfactory/translations/nl.json index 635076f..2d14c3d 100644 --- a/custom_components/satisfactory/translations/nl.json +++ b/custom_components/satisfactory/translations/nl.json @@ -20,19 +20,6 @@ } }, "entity": { - "event": { - "player_activity": { - "name": "Speler activity", - "state_attributes": { - "event_type": { - "state": { - "player_joined": "Speler verbonden", - "player_left": "Speler verbroken" - } - } - } - } - }, "sensor": { "num_connected_players": { "name": "Verbonden spelers" @@ -54,9 +41,6 @@ }, "game_phase": { "name": "Spelfase" - }, - "server_health": { - "name": "Servergezondheid" } } } From 53b84b4e191f772fccf56129c662e262b6ef96ca Mon Sep 17 00:00:00 2001 From: Programmer-Timmy Date: Wed, 4 Mar 2026 14:34:30 +0100 Subject: [PATCH 8/8] Remove entry_id from player event data This change eliminates the entry_id from the player_joined and player_left event data, streamlining the event payload. --- custom_components/satisfactory/event.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/satisfactory/event.py b/custom_components/satisfactory/event.py index 2410f15..6838c21 100644 --- a/custom_components/satisfactory/event.py +++ b/custom_components/satisfactory/event.py @@ -70,7 +70,6 @@ def _handle_coordinator_update(self) -> None: self._trigger_event( "player_joined", { - "entry_id": self._entry_id, "num_connected_players": new_players, "player_limit": player_limit, }, @@ -79,7 +78,6 @@ def _handle_coordinator_update(self) -> None: self._trigger_event( "player_left", { - "entry_id": self._entry_id, "num_connected_players": new_players, "player_limit": player_limit, },