Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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)

<img src="/custom_components/satisfactory/brand/icon.png" width="100" />
<img src="https://raw.githubusercontent.com/Programmer-Timmy/ha-satisfactory/main/custom_components/satisfactory/brand/icon.png" width="100" />

# Ha-satisfactory

Expand Down Expand Up @@ -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.
Comment thread
Programmer-Timmy marked this conversation as resolved.

## Removal

Expand Down
2 changes: 1 addition & 1 deletion custom_components/satisfactory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
87 changes: 87 additions & 0 deletions custom_components/satisfactory/event.py
Original file line number Diff line number Diff line change
@@ -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)},
Comment thread
Programmer-Timmy marked this conversation as resolved.
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,
},
)
Comment thread
Programmer-Timmy marked this conversation as resolved.
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()
13 changes: 13 additions & 0 deletions custom_components/satisfactory/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
13 changes: 13 additions & 0 deletions custom_components/satisfactory/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
95 changes: 95 additions & 0 deletions tests/satisfactory/test_event.py
Original file line number Diff line number Diff line change
@@ -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