Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HASS Integration #566

Open
cvele opened this issue Oct 3, 2024 · 9 comments
Open

HASS Integration #566

cvele opened this issue Oct 3, 2024 · 9 comments

Comments

@cvele
Copy link
Contributor

cvele commented Oct 3, 2024

I’ve been exploring the creation of a Home Assistant integration, and so far, my approach involved hosting a solution on the HASS instance, using the API to display dashboards. However, this method introduces unnecessary overhead since it requires additional resources for relatively minor functionality. Additionally, it's limited to dashboards and user interactions via clicks, which prevents using voice assistants to start games.

Recently, I’ve come up with a new idea: building a custom Home Assistant integration that generates virtual switches for each game in the library, essentially replicating part of the Playnite Web frontend/API functionality. The concept is to subscribe to the Playnite Web MQTT topic and create switches based on game installation, uninstallation, and synchronization events.

My primary concern is ensuring this remains aligned with your work, @andrew-codes. Would you be open to collaborating or coming to an agreement on how the messages should be structured for consistency?

A cleaner solution that could avoid the need for a custom integration altogether would be to integrate MQTT device discovery directly into the Playnite Web plugin. However, I realize this might be beyond the current focus and scope. Here’s a link to Home Assistant’s MQTT device discovery documentation.

I’d love to hear your thoughts. I already have a proof of concept in place, although it’s not yet fully functional. Here's a glimpse of it:

switch.py

import logging
import json
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.mqtt import async_subscribe

LOGGER = logging.getLogger(__name__)

async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
    if discovery_info is None:
        return

    switch = PlayniteGameSwitch(discovery_info, hass)
    async_add_entities([switch])

class PlayniteGameSwitch(SwitchEntity):
    def __init__(self, game_data, hass):
        self._name = game_data.get('name')
        self._state = False
        self._game_id = game_data.get('id')
        self._game_data = game_data
        self.hass = hass

        # Subscribe to MQTT events for game state changes
        self.topic = f"playnite/response/game/state"
        async_subscribe(self.hass, self.topic, self.mqtt_message_received)

    @property
    def name(self):
        return self._name

    @property
    def is_on(self):
        return self._state  # Reflect the running state of the game

    async def turn_on(self, **kwargs):
        """Start the game via MQTT."""
        self._state = True
        self.schedule_update_ha_state()

        # Publish MQTT message to start the game
        payload = {
            "game": {
                "id": self._game_id,
                "gameId": self._game_data.get('gameId'),
                "name": self._game_data.get('name'),
                "platform": {
                    "id": self._game_data['platform']['id'],
                    "name": self._game_data['platform']['name']
                },
                "source": self._game_data.get('source'),
                "install": not self._game_data.get('isInstalled', False)
            }
        }

        topic = f"playnite/request/game/start"
        await self.hass.components.mqtt.async_publish(topic, json.dumps(payload))

    async def turn_off(self, **kwargs):
        """Stop the game via MQTT."""
        self._state = False
        self.schedule_update_ha_state()

        # Publish MQTT message to stop the game
        payload = {
            "game": {
                "id": self._game_id,
                "gameId": self._game_data.get('gameId'),
                "name": self._game_data.get('name'),
                "platform": {
                    "id": self._game_data['platform']['id'],
                    "name": self._game_data['platform']['name']
                },
                "source": self._game_data.get('source')
            }
        }

        topic = f"playnite/request/game/stop"
        await self.hass.components.mqtt.async_publish(topic, json.dumps(payload))

    async def mqtt_message_received(self, msg):
        """Handle messages for game run state."""
        payload = json.loads(msg.payload)

        if payload.get("id") == self._game_id:
            run_state = payload.get("runState")
            if run_state == "Running":
                self._state = True
            else:
                self._state = False

            self.schedule_update_ha_state()  # Update switch state in Home Assistant

mqtt_handler.py


import json
import logging
from homeassistant.components import mqtt

_LOGGER = logging.getLogger(__name__)

async def setup_mqtt_subscription(hass, topic_base, handle_message):
    """Set up an MQTT subscription to listen for game discovery messages."""
    async def message_received(msg):
        try:
            # Parse the MQTT message payload
            data = json.loads(msg.payload)
            # Call the message handler with the parsed data
            await handle_message(hass, data)
        except Exception as e:
            _LOGGER.error(f"Failed to process MQTT message: {e}")

    # Subscribe to the base topic
    await mqtt.async_subscribe(hass, f"{topic_base}/#", message_received)
    _LOGGER.info(f"Subscribed to MQTT topic: {topic_base}/#")

init.py

import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.discovery import async_load_platform  # Import directly
from .mqtt_handler import setup_mqtt_subscription

_LOGGER = logging.getLogger(__name__)

DOMAIN = "playnite_web_mqtt"

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Set up the Playnite Web MQTT from a config entry."""
    topic_base = entry.data.get("topic_base")
    
    if not topic_base:
        _LOGGER.error("No topic base provided in the config entry")
        return False

    # Set up the MQTT subscription
    await setup_mqtt_subscription(hass, topic_base, handle_game_discovery)

    return True

async def handle_game_discovery(hass: HomeAssistant, message: dict):
    """Handle the discovery of a game and create a switch for each game."""
    game_id = message.get('id')
    game_name = message.get('name')

    if not game_id or not game_name:
        _LOGGER.error("Invalid game data received, skipping.")
        return

    switch_data = {
        'id': game_id,
        'name': game_name
    }

    # Dynamically load the switch platform for the discovered game
    hass.async_create_task(
        async_load_platform(hass, 'switch', DOMAIN, switch_data, {})  # Use direct import of async_load_platform
    )
@github-project-automation github-project-automation bot moved this to Needs Triage 🗃️ in Playnite-Web Oct 3, 2024
@cvele
Copy link
Contributor Author

cvele commented Oct 3, 2024

Additionally, it would be beneficial to have a message or topic that monitors the global on/off status of Playnite, allowing us to verify if it’s running. This would help ensure proper synchronization and control.

@andrew-codes
Copy link
Owner

This is amazing! It is interesting, because my primary purpose for creating Playnite Web was to automate my game room experience; with Playnite Web being the most overpowered "remote control." It has been challenging to do this while also keeping the wider appeal of sharing games without needing automation control.

This is not documented, and I've been meaning to create a blog post about it, but you can integrate Playnite Web with Home Assistant already today. This is assuming that Playnite Web is the dashboard/touch point for interacting with the system as opposed to a Home Assistant dashboard. I hope that Playnite Web is a better experience to find and select a game to play that a Home Assistant dashboard, but that I also understand that may not be widely agreed upon.

Playnite Web has some features locked behind a single user login. You provide the username, password, and secret via environment variables. If this is done, then you will be able to sign into Playnite Web. Once signed in, the game details page has a Play on platform button (shown below). Clicking this button publishes an MQTT message. However, that message is not picked up by the Plugin. This is because there may be additional automation needed prior to starting a game, such as turning on devices, setting volumes, etc.

I have Home Assistant trigger a start game activity from the published message. This turns on the TV, AVR, sets inputs and volumes, etc.; letting Home Assistant handle the automation aspect.

For PC games, when starting a game, I publish MQTT message from Home Assistant that runs a shell command on my PC to start Playnite.Desktop.exe with the game's ID as a CLI parameter. This starts the game. I also track the game's process ID back in Home Assistant. I do all of this with HASS.agent, which runs over MQTT.

For PlayStation games, I use a custom ps5 mqtt docker service. This is the foundation of the ps5-mqtt version, which is not more feature rich, and I plan to replace mine with one day soon.

Overall, this is a working way to start, stop and restart games. Unfortunately, it does not provide me with the controls to adjust the volume, use a different volume preset and other things that are found exclusively in Home Assistant. How to bridge that game is something I have not ironed out quite yet.

All this works well, but none of the messages from Playnite Web's start, stop, restart actions are openly available in the documentation yet. As I said, it is something on my radar, but I just haven't done it yet. If this sounds like a solid approach to automation, then let's work towards using Playnite Web as the "remote" so to speak. If not, then that is ok, too. The entire reason for adding the graph API was to allow others to build their own experiences. One note on this is: my vision is for the graph API to be the extension point versus the messages coming from the Plugin. This is one reason I've started a real-time subscription model for the graph API.

Nothing is stopping others from using those messages of course, but I do not intend on breaking changes to them bumping the major version, so if there are breaking changes you may not know based on the version number. What I'm about to say is more work, but the translation of games into messages that HA discovery understands could come from an external service; one that listens to the graph API and publishes changes? This will need the graph API to be leveled up some, but I'm not sold on the Plugin publishing what would directly become entities to Home Assistant. I'm a perfect example of not wanting that functionality.

Alternatively, there could be a setting in the Plugin to enable or disable that feature. In either case, this is a great discussion to have and iron out and I'm happy to have someone interested enough to help solve it.

image

@andrew-codes
Copy link
Owner

Additionally, it would be beneficial to have a message or topic that monitors the global on/off status of Playnite, allowing us to verify if it’s running. This would help ensure proper synchronization and control.

This is a great idea. I'll add this as an issue. Currently, I always try to kill Playnite and start it fresh via my automation. However, knowing if it is running or not is a good feature to have.

@andrew-codes
Copy link
Owner

andrew-codes commented Oct 4, 2024

As a follow up to my long post, what do you think about having Playnite Web act as the remote control and leveling up its capabilities in this regard versus having games show up as entities in Home Assistant? Knowing this and how others feel will help guide the solution.

Also, I'm not opposed to defining a structure somehow to make this work. It will simply mean breaking changes to the messages will need to be reflected in the release version.

@cvele
Copy link
Contributor Author

cvele commented Oct 5, 2024

I've already explored the Playnite Web plugin's code and implemented a HASS Python integration that subscribes to the Playnite MQTT topic. The integration creates game switches based on MQTT discovery events, allowing users to start and stop games via Home Assistant. While starting a game works fine, stopping a game via MQTT doesn't stop the actual game, which I suspect might be a Playnite-side issue.

A major challenge has been maintaining an accurate state for the switches. If we could access a persistent state of the running game process (using game IDs), it would greatly improve the reliability of the integration. I'm not familiar with Playnite's internal workings, but it would make sense if a process list was maintained internally, and if that could be published over MQTT, it could be used to reflect the real-time state of games within Home Assistant.

I'd love to hear your thoughts on collaborating to ensure consistency in how MQTT messages are structured between our implementations. As an alternative, a direct Playnite Web plugin integration with MQTT device discovery would avoid the need for a custom integration and make things smoother overall. I realize this might be beyond current scope, but I’d be happy to contribute if needed.

@cvele
Copy link
Contributor Author

cvele commented Oct 5, 2024

Screenshot 2024-10-05 at 11 10 28 Screenshot 2024-10-05 at 11 10 15

@cvele
Copy link
Contributor Author

cvele commented Oct 5, 2024

One area for improvement is the inconsistency in how releases are segmented by game ID, while commands are not. This creates confusion, especially when controlling multiple Playnite instances. In such a scenario, all instances would respond to a game start event, making it difficult to manage each instance independently.

@andrew-codes
Copy link
Owner

andrew-codes commented Oct 6, 2024

@cvele , this is a lengthy reply, so let me organize it to better communicate the ideas. I plan to talk about the following points, in this order:

  1. General response to the Home Assistant integration; spoiler alert: awesome and magnificent work! :-)
  2. Tracking game state (today)
  3. Outline the problem with stopping games
  4. Describe how I stop games in my personal setup (today)
  5. Brainstorm Playnite Web and HA integration intersections
    a. Brainstorm ideas on how to stop games (for the future)
    b. Brainstorm games in HA via discovery MQTT messages via Playnite Web.
    c. Brainstorm better ways (if any) to have future discussions (GitHub Issue vs GitHub Discussions vs Discord vs Gitter)

General Response to HA Integration

This is amazing and I fully support this! There are some things Playnite Web can do to help make this easier to achieve from the integration's side. I'll explore this more in the brainstorming sections.

Tracking Game State

Note that game state is already track-able via a few MQTT topics. When the game starts, stops, installs, uninstalls, etc. a message is published for each state. This message includes some game information, including the process ID of the game. I use these messages, for example, to set the currently running game process ID in Home Assistant.

Stopping a Game: Challenges

I completely forgot Playnite Web's extension was subscribed to a start game topic. Unfortunately, there is no built-in Playnite API for Playnite extensions to stop a game. However, given the game's process ID is made available in some form, the process can be killed manually.

Starting/Stopping a Game (the long way/work around) in my personal setup

In my setup, I opted to start a game via a MQTT message having a shell command to start Playnite's Desktop executable with a CLI parameter for the game ID. Both ways would work, but the benefit of using the shell command is that it doesn't matter whether Playnite is running already or not. If not, then it starts it, if it is, then there is a CLI parameter to stop other running instances. This requires a way to run this command on the host machine, e.g. I use HASS.agent (it also has a HA integration) to carry out this.

However, I have not implemented a message to stop an already running game. Again, the reason is that the API provided for Playnite exposed to extensoins does not have this capability. With this said, I've gotten around this in a roundabout way.

In home assistant I have a stored state (input_text) that is set on incoming MQTT messages for Playnite Web's game state topic. playnite/{deviceID}/response/game/state. When the game starts, stops, installs, etc. a message is published for each state. This message includes the process ID of the game. This topic can be used to track the state of the currently playing game.

However, having just the process ID is not that helpful. But we can use the process' ID in a PowerShell command to kill that process. I do this by setting up a HASS.agent command that is invoked with PowerShell. The payload of the command is the PowerShell I want it to execute on the gaming pc. I then publish this message from Home Assistant when I need to stop the game. e.g. below:

data:
  qos: "2"
  topic: homeassistant/button/gaming-pc/pwsh/action
  payload: "-c \"Stop-Process -Id {{ states('input_text.current_game_process_id') }}\""
alias: Stop game process by ID
action: mqtt.publish

To stop Playnite, I do the same but with Playnite's process ID. I got this information via subscribing to the MQTT message I set up in HASS.agent. The HASS.agent sensor is a PowerShell command that runs/updates every N seconds. For the script, I do something like get-process | Where -title "Playnite" or something to that effect.

Again, this is a roundabout way to achieve the goal and most certainly not easy for beginners that just want it to work. Hopefully, this provides some context that may inform us of a better solution.

Brainstorming

How to Interface with Playnite Web

The first thing to consider is how we expect external systems to interact with Playnite Web. There are three ways on the table that I can see:

  1. via the Graph API
  2. via internal MQTT messages from the Playnite Web Extension
  3. via MQTT messages from Playnite Web (app)

Graph API

This was the intended mechanism to build custom experiences. There are some tangible benefits to using it, as well as some drawbacks. Note that although the Graph API may not support every feature today, the intention is for it to do so in the near future. With it you can track games states, start, stop, restart games, and, of course, query for game information. The biggest advantage here is the last one.

Playnite web's API exposes games in a separate way than Playnite proper. There is an added concept in the graph that is not present in Playnite; the distinction between a game and a release of a game on a specific platform. For a specific example:

  • 1 game in my library, named "The game"
  • Released on 3 platforms: PS4, PS5, PC
  • I can start "The game" on PS4, PS5, PC via Steam or PC via Epic.

The above in Playnite proper, there are 4 games. The games look like the below

  1. The Game, on platform PS4, platform PS5, source is Sony PlayStation
  2. The Game, on platform PS4, platform PS5, source is Sony PlayStation
  3. The Game, on platform Windows, Linux, OSX, source is steam
  4. The Game, on platform Windows, Linxus, OSX, source is Epic

As you can see, the process of getting Playnite Web's relationships is not always straight forward. The PS games both have the same platforms, PS4 and PS5; even though one is on PS4 and one is on PS5. Note that this dynamic is not always consistent either. The last two we know are both PC (or computer), but if we wanted to play one, we would want to be able to pick which one from the two as they may have different saves, etc.

The Graph API already does all the gymnastics to do this in a performant and extensible way. This is why it is the preferred way to interact with Playnite Web. However, the downside is that graph calls may not be the ideal way to receive the information, such as the HA integration makes it easier to work with MQTT directly instead.

Internal MQTT Messages

Another way is to directly subscribe to the MQTT messages of Playnite Web's extension. It also does some of the heavy lifting mentioned above, though not guaranteed to do all. The problem I see with this approach is that these messages are intended to be internal to Playnite Web. Even though they are on a message bus such that these messages can be consumed by other applications, the internal nature is not about preventing this, but more so that these messages will be customized for Playnite Web only. This means their shape can change without notice, as they were never intended to be consumed by others. There may be a middle ground to get a win-win. This is the third possibility I see.

MQTT Messages

Instead of consuming the internal messages, there is nothing stopping us from publishing new messages on the message bus; ones that are not internal, but react to the changes, etc. from Playnite Web. This could be built directly into the Playnite Web's application for example. Another possibility is to create a separate process that does only this one job. It can subscribe to the graph API, which again is the preferred and most feature rich mechanism and translate all events into MQTT messages of whatever shape is ideal.

Although it would be more compact to encorporate directly in the Playnite Web application (the web app code), I think I'd prefer it to not be baked in. My reasoning is that it is not really a responsiblity of Playnite Web and is more of a enabling a specific use case/experience. With that said, I do think this can be baked into Playnite Web's repository of code. This will enable automated publishes and releases with the rest of Playnite Web. This also means the tight partnership can mitigate breaking changes in the Graph API from impacting users while they wait for the MQTT message translation/publisher to be updated to account for said breaking changes.

What do you think of this third approach? I would love to have this in the repo and aid in its development. I think this will buy you the precision of MQTT messages to power the HA integration, also, such as using the discovery format to automatically create game entities; each with their own associated actions to start, etc. Note, that pulling the HA integration into this repo is also a possibility that I'm not opposed to. Again, it may make the integration more responsive to changes within Playnite Web.

This will also help users create automation for staring games beyond simply starting the executable on the host; such as turning on TVs, AVRs, lights, etc. This is because, with the above integration, these events can trigger these events in HA directly. I do think there is a need to separate the external start a game from the internal starting game executable. The reason is that users, such as myself, may want to turn on devices, TVs, AVRS, lights, ensure the remote host is actually turned on, etc. BEFORE the game executable is started.

Stopping Games

The above does not entirely address the stopping of games though. For this, I'm wondering if the Playnite Web extension does this, just not with the Playnite API. We could, for example, start a separate thread to kill the process by its ID from the extension. This would be ideal since it is already on the host gaming PC. This does account for other Platforms, such as PlayStations, though. There would need to be a conditional to check if the game to stop is running on the machine versus some other platform. The actual trigger would be an internal MQTT message; one that could also be translated to be used by other systems to stop other platforms, such as ps5-mqtt to stop PlayStations.

Wrapping Up and Feedback

What do you think about the above proposals? If this lands as sounding like a good thing, then I can create some issues in GitHub. I think I may also create some ADRs (architectural decision record documents) in the repo to track major decisions like these.

Future Discussions

Given the length of this reply and the lack of fluidity in collaborating, do you think GitHub issues is the right place for internal collaborators to collaborate? If so, then we can continue here. A while ago, I set up a Gitter (based on open-source MATRIX chat) to provide a place for the community to chat beyond GitHub issues. I chose this over Discord simply because Gitter used to be the mechanism for repos. However, I use Discord and many tech communities, such as the official React community, are on Discord, so I do not mind using it instead. Open to thoughts. If we decide on using Discord, then I'll need to update the README with the community links. One additional note, and in favor of Discord, is that @matt-quests-for-tacos and I use Discord when pair programming on Playnite Web already. Again, open to thoughts.

@cvele
Copy link
Contributor Author

cvele commented Oct 10, 2024

Been postponing answering here as I was wrapping up the HASS integration for HACS (now available here: Playnite Web MQTT).

I think one of the major issues here is the time zone difference. I'm based in CET, and if your time zone is offset significantly, pair programming might be a challenge. That said, there are a few different threads we need to pick up on, so I'll try to break them down for clarity.

Playnite Web Architecture: The existing architecture involving Playnite, the Playnite Web plugin, and Playnite Web frontend (GraphQL API connected to MongoDB) feels overly complex for the task at hand. Hosting MongoDB just to interact with the Playnite Web seems like an unnecessary overhead. Ideally, the Playnite Web plugin could maintain an in-memory database, populated through MQTT instead of MongoDB, and request library information only when it needs to refresh or update a game. This would streamline the architecture and reduce the complexity.

GraphQL API and Synchronization: While GraphQL adds a level of sophistication, it introduces synchronous requests and polling, which may not be compelling in this scenario. Given that our task revolves around game control and state monitoring, a message-driven approach (e.g., MQTT) aligns better with event-driven architectures like Home Assistant.

Message Structure and MQTT Discovery: The current MQTT messages seem inconsistent, especially when it comes to game IDs and handling multiple Playnite instances. I agree that aligning with existing standards, such as Home Assistant’s MQTT discovery, would offer a lot of benefits, including automatic device discovery and better state management out of the box. A more consistent and standardized message strategy would improve integration reliability across different setups.

On stopping games The inability to reliably stop games via Playnite is a roadblock, especially when dealing with platforms like Steam, where process IDs aren’t always consistent or accessible. Your approach of executing a PowerShell script to terminate non-whitelisted processes before starting a new game is a clever workaround, and I also integrate with HASS.Agent similarly. However, as you've pointed out, this is more of a hack than a proper solution.

Ideally, the Playnite Web plugin should offer a more standardized way to handle this, perhaps through a built-in mechanism for terminating games, rather than relying on external scripts. Until something more robust is provided, pinning this topic seems like the right move, but we should definitely keep it on the radar as part of our longer-term goals.

Let’s continue to explore how we can integrate this functionality in the future—whether through improvements to the Playnite Web plugin or some other solution that avoids the current tomfoolery we're resorting to.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Needs Triage 🗃️
Development

No branches or pull requests

2 participants