video showcases proof of concept layed out here

Before we dive in, make sure you have the following:

  • A Windows PC running your gaming system.
  • Docker Docker for Windows for example.
  • MongoDB on host which is accessible from both, PC and Home Assistant.
  • Home Assistant installed and configured
  • Playnite installed on your PC to manage your games and applications.
  • MQTT integration enabled in Home Assistant for messaging.
  • HASS.Agent or IOTLink (note: IOTLink is deprecated) for PC control.

Here’s an overview of our setup, presented as a high-level Mermaid diagram. This should help you see how everything from MQTT messaging, mongo, sensors to lovelace fits together.

flowchart TD subgraph "Windows PC" Playnite[Playnite] MQTT_Client[MQTT Client] Playnite_Web[Playnite Web] HASS_Agent[HASS.Agent] Playnite --> MQTT_Client Playnite --> Playnite_Web[PlayniteWeb Plugin] Playnite --> HASS_Agent subgraph Docker_Container[Docker] playnite-web-game-db-updater[playnite-web-game-db-updater] end end MQTT_Client --> MQTT_Broker[MQTT Broker] Playnite_Web --> MQTT_Broker HASS_Agent --> MQTT_Broker playnite-web-game-db-updater --> MongoDB[MongoDB] Node_API[Node.js API] --> MongoDB subgraph Home_Assistant[Home Assistant] MQTT_Sensors[MQTT Sensors] Templates[Templates] Lovelace_Dashboard[Lovelace Dashboard] MQTT_Broker end MQTT_Broker --> MQTT_Sensors MQTT_Broker --> Templates Lovelace_Dashboard --> Node_API Lovelace_Dashboard --> MQTT_Sensors

Setting Up Your PC

First, make sure you have Playnite installed. Then, install the PlayniteWeb plugin by downloading the latest *.pext file. At the time of this writing, the most recent version is 3.0.1. After restarting Playnite, navigate to the addon settings, input your MQTT broker details, and save them. A new menu item will appear: Extensions -> Playnite Web -> Sync Library. Use this to synchronize your game library state.

Next, set up the playnite-web-game-db-updater with the necessary environment variables for MongoDB and MQTT Broker access, and run it. This will continuously update your MongoDB with the latest game data from your library via MQTT. You will need to do the initial one manually.

docker run -d --name playnite-web-game-db-updater \
  -e MONGO_URI=mongodb://<username>:<password>@<your-mongo-db-host>:27017/games \
  -e MQTT_BROKER_URL=mqtt://<your-mqtt-broker> \
  andrewcodes/playnite-web-game-db-updater

Replace <username>, <password>, <your-mongo-db-host>, and <your-mqtt-broker> with your actual details.

Setting Up Home Assistant

Configuring Home Assistant involves several steps: initially, we configure sensors to monitor MQTT topics established by PlayniteWeb; next, we develop scripts for launching and terminating games; and finally, we design a custom Lovelace card to integrate into our dashboard.

Configuring Sensors

This snippet defines sensor.playnite_game_state sensor which we will use to understand which app is currently running.

mqtt:
  sensor:
      - name: "Playnite Game State"
      state_topic: "playnite/playniteweb_<<your-computername>>/response/game/state"
      value_template: "{{ value_json.state }}"
      json_attributes_topic: "playnite/playniteweb_<<your-computername>>/response/game/state"
      json_attributes_template: >
        {
          "processId": "{{ value_json.processId }}",
          "gameId": "{{ value_json.gameId }}"
        }        

Creating scripts to launch apps through Playnite

These scripts serve as a templates based on my setup; you will need to modify them to fit your specific needs. However, they effectively illustrate the intended workflow and objectives.

script.launch_playnite_app:

alias: Launch Playnite App
sequence:
  - variables:
      old_process_id: "{{ state_attr('sensor.playnite_game_state', 'processId') }}"
      old_game_id: "{{ state_attr('sensor.playnite_game_state', 'gameId') }}"
  - parallel:
      - alias: Terminate currently running app
        if:
          - condition: template
            value_template: >-
              {{ old_process_id is not none and old_process_id | string | length
              > 0 and old_game_id != playnite_game_id }}              
        then:
          - service: script.stop_playnite_app
            metadata: {}
            data:
              process_id: "{{ old_process_id }}"
          - wait_for_trigger:
              - platform: state
                entity_id:
                  - sensor.playnite_game_state
            timeout:
              hours: 0
              minutes: 0
              seconds: 5
              milliseconds: 0
          - delay:
              hours: 0
              minutes: 0
              seconds: 1
              milliseconds: 0
      - service: script.turn_mediacenter_screen_on
        data: {}
      - service: script.unmute_and_set_volume_on_mediacenter
        data: {}
      - service: script.restart_zeeray
        metadata: {}
        data: {}
  - alias: Run Playnite Game
    service: mqtt.publish
    data:
      topic: iotlink/workgroup/<<your-computer-name>>/commands/run
      qos: "2"
      payload: >-
        {"command":
        "C:\\\\Users\\\\<<username>>\\\\AppData\\\\Local\\\\Playnite\\\\Playnite.FullscreenApp.exe",  
        "args": "--startfullscreen --start {{ playnite_game_id }}", "path": "C:\\\\Users\\\\<<username>>\\\\AppData\\\\Local\\\\Playnite", "visible": true,   "fallback": true }        
    - alias: Focus launched app
    if:
      - condition: template
        value_template: "{{ old_game_id != playnite_game_id }}"
    then:
      - wait_for_trigger:
          - platform: state
            entity_id:
              - sensor.playnite_game_state
            attribute: gameId
            to: "{{ playnite_game_id }}"
        timeout:
          hours: 0
          minutes: 0
          seconds: 5
          milliseconds: 0
      - variables:
          process_id: "{{ state_attr('sensor.playnite_game_state', 'processId') }}"
      - alias: Maximize App By Pid
        service: mqtt.publish
        metadata: {}
        data:
          topic: iotlink/workgroup/<<your-computer-name>>/commands/run
          qos: "2"
          payload: |-
            {
              "command": "cmd.exe",
              "args": "/c C:\\Users\\<<your-username>>\\Documents\\SmartHome\\run_maximize_by_pid.vbs {{ process_id }}",
              "visible": false,
              "fallback": true
            }            
mode: single
icon: mdi:microsoft-xbox-controller
fields:
  playnite_game_id:
    selector:
      text: null
    name: Playnite Game ID
    description: Database game id of app in playnite

script.stop_playnite_app:

alias: Stop Playnite App
sequence:
  - alias: Terminate App
    service: mqtt.publish
    metadata: {}
    data:
      topic: iotlink/workgroup/<<your-computer-name>>/commands/run
      qos: "2"
      payload: |
        {
          "command": "cmd.exe",
          "args": "/C taskkill /PID \"{{process_id}}\" /F",
          "user": "",
          "visible": true,
          "fallback": true
        }        
  - alias: Run Playnite
    service: mqtt.publish
    metadata: {}
    data:
      topic: iotlink/workgroup/<<your-computer-name>>/commands/run
      qos: "2"
      payload: >-
        {"command":
        "C:\\\\Users\\\\<<your-username>>\\\\AppData\\\\Local\\\\Playnite\\\\Playnite.FullscreenApp.exe",  
        "args": "--startfullscreen", "path": "C:\\\\Users\\\\<<your-username>>\\\\AppData\\\\Local\\\\Playnite",   "user":
        "", "visible": true, "fallback": true }        
  - alias: Maximize Playnite
    service: mqtt.publish
    metadata: {}
    data:
      topic: iotlink/workgroup/<<your-computer-name>>/commands/run
      qos: "2"
      payload: |-
        {
          "command": "cmd.exe",
          "args": "/c C:\\Users\\<<your-username>>\\Documents\\SmartHome\\maximize-playnite.vbs",
          "path": "",
          "user": "",
          "visible": false,
          "fallback": true
        }        
mode: restart
icon: mdi:microsoft-xbox-controller-off
fields:
  process_id:
    selector:
      text: null
    name: process_id
    description: process_id

Custom Playnite Home Assistant Dashboard

Setting Up the Node API

To access the database, we need a BFF API. Here’s a simple version in Node:

const express = require('express');
const { MongoClient } = require('mongodb');
const cors = require('cors');

const app = express();
app.use(express.json());
app.use(cors());

const uri = 'mongodb://<username>:<password>@<host>:27017';
const client = new MongoClient(uri, { useNewUrlParser: true, useUnifiedTopology: true });

async function run() {
  try {
    await client.connect();
    const database = client.db('games');
    const gamesCollection = database.collection('game');
    const assetsCollection = database.collection('assets');

    app.get('/games', async (req, res) => {
      try {
        const games = await gamesCollection.find({}).toArray();
        res.json(games);
      } catch (err) {
        res.status(500).json({ error: err.message });
      }
    });

    app.get('/assets/:id', async (req, res) => {
      try {
        const asset = await assetsCollection.findOne({ id: req.params.id });
        res.send(Buffer.from(asset.file.buffer, 'binary'));
      } catch (err) {
        res.status(500).json({ error: err.message });
      }
    });

    app.listen(3000, () => {
      console.log('Server running on port 3000');
    });
  } catch (err) {
    console.error(err);
  }
}

run().catch(console.error);

To dockerize this API you can use something like:

FROM node:18

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm install
COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

Custom Game Picture Card

To display your games on the dashboard, craft a custom card and name the file games-picture-card.js. Save it in the /www directory of your Home Assistant installation. Then, incorporate /local/games-picture-card.js into your dashboard resources, which you can manage directly through the UI.

This card serves as a template, modify it to serve your usecase

class GamesPictureCard extends HTMLElement {
  constructor() { 
    super(); 
    this.attachShadow({ mode: 'open' }); 
    this.currentGame = null; 
    this.currentProcessId = null; 
    this.lastFetched = 0; 
    this.fetchInterval = 5000; 
    this.eventSubscribed = !1 
  }

  set hass(hass) {
    this._hass = hass; 
    if (!this.content) { 
      this.shadowRoot.innerHTML = this.getTemplate(); 
      this.content = this.shadowRoot.querySelector("#games-container"); 
      this.fetchAndDisplayGames() 
    }
    this.updateCurrentGameFromSensor(hass.states['sensor.playnite_game_state']); 
    if (!this.eventSubscribed) { 
      this._hass.connection.subscribeEvents(() => { 
        this.updateCurrentGameFromSensor(this._hass.states['sensor.playnite_game_state']) 
      }, 'state_changed'); 
      this.eventSubscribed = !0 
    }
  }

  getTemplate() {
    return `
      <style>
        .card-content {
          display: flex;
          flex-wrap: wrap;
          overflow-y: scroll;
          height: 580px;
        }
        .game-card {
          display: flex;
          flex-direction: column;
          align-items: center;
          margin: 4px;
          border-radius: 8px;
          width: calc(25% - 8px);
          position: relative;
          cursor: pointer;
        }
        .overlay {
          position: absolute;
          top: 10px;
          left: 10px;
          background-color: rgba(0, 0, 0, 0.5);
          color: white;
          padding: 5px 10px;
          border-radius: 5px;
        }
        .stop-icon {
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          background-color: rgba(0, 0, 0, 0.8);
          border-radius: 50%;
          padding: 10px;
        }
        .grayscale {
          filter: grayscale(100%);
          pointer-events: none;
        }
      </style>
      <div class="card-content" id="games-container"></div>
    `;
  }

  updateCurrentGameFromSensor(sensorState) {
    if (sensorState && sensorState.attributes.gameId && sensorState.state === "started") { 
      this.currentGame = sensorState.attributes.gameId; 
      this.currentProcessId = sensorState.attributes.processId; 
    } else { 
      this.currentGame = null; 
      this.currentProcessId = null; 
    }
    this.fetchAndDisplayGames();
  }

  async fetchAndDisplayGames() {
    const now = Date.now(); 
    if (now - this.lastFetched < this.fetchInterval) return;
    this.lastFetched = now; 
    try {
      const response = await fetch('https://<<node-js-api-hostname>>/games?isInstalled=true'); 
      if (!response.ok) throw new Error('Network response was not ok');
      const data = await response.json(); 
      this.displayGames(data);
    } catch (error) { 
      console.error('Error fetching games:', error); 
      this.content.innerHTML = '<p>Error fetching games</p>'; 
    }
  }

  displayGames(data) { 
    this.content.innerHTML = ''; 
    data.forEach(game => { 
      const card = this.createGameCard(game); 
      this.content.appendChild(card); 
    });
  }

  createGameCard(game) {
    const card = document.createElement('div'); 
    card.classList.add('game-card'); 
    if (game.assets.length > 0) { 
      const asset = game.assets[0]; 
      const img = document.createElement('img'); 
      img.src = `https://<node-js-api-hostname>${asset.file}`; 
      img.alt = game.name; 
      img.style.maxWidth = "99%"; 
      img.style.borderRadius = "12px"; 
      img.style.alignSelf = "center"; 
      card.appendChild(img); 
    }
    if (game.id === this.currentGame) { 
      const overlay = this.createOverlay("Currently Running"); 
      card.appendChild(overlay); 
      const stopIcon = this.createStopIcon(); 
      stopIcon.addEventListener('click', (event) => { 
        event.stopPropagation(); 
        this.stopGame(this.currentProcessId); 
      }); 
      card.appendChild(stopIcon); 
      card.addEventListener('click', () => { 
        this.stopGame(this.currentProcessId); 
      });
      this.removeGrayscaleAndEnableClicks();
    } else { 
      card.addEventListener('click', () => { 
        this.launchGame(game); 
      }); 
    }
    return card;
  }

  createOverlay(text) { 
    const overlay = document.createElement('div'); 
    overlay.classList.add('overlay'); 
    overlay.textContent = text; 
    return overlay; 
  }

  createStopIcon() { 
    const stopIcon = document.createElement('div'); 
    stopIcon.classList.add('stop-icon'); 
    stopIcon.innerHTML = '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="white" d="M6 6h12v12H6z"/></svg>'; 
    return stopIcon; 
  }

  async stopGame(processId) { 
    this.applyGrayscaleAndDisableClicks(); 
    this._hass.callService('script', 'stop_playnite_app', { process_id: processId }); 
    setTimeout(() => this.removeGrayscaleAndEnableClicks(), 10000); 
  }

  launchGame(game) { 
    this.applyGrayscaleAndDisableClicks(); 
    this._hass.callService('script', 'launch_playnite_app', { playnite_game_id: game.id }); 
    setTimeout(() => this.removeGrayscaleAndEnableClicks(), 10000); 
  }

  applyGrayscaleAndDisableClicks() { 
    const cards = this.shadowRoot.querySelectorAll('.game-card'); 
    cards.forEach(card => card.classList.add('grayscale')); 
  }

  removeGrayscaleAndEnableClicks() { 
    const cards = this.shadowRoot.querySelectorAll('.game-card'); 
    cards.forEach(card => card.classList.remove('grayscale')); 
  }

  setConfig(config) { 
    this.config = config; 
  }

  getCardSize() { 
    return 3; 
  }
}

customElements.define('games-picture-card', GamesPictureCard);
window.customCards = window.customCards || [];
window.customCards.push({
    type: "games-picture-card",
    name: "Games Picture Card",
    description: "A custom card to display game images and execute a script with the game ID."
});

Once resources are reloaded (break cache) you will be able to add custom lovelace card, eg:

type: custom:games-picture-card

Final glue

You might have spotted the <<node-js-api-hostname>> placeholder in the card above; this is the crucial link that enables our JavaScript to interact with MongoDB. Keep in mind, this setup is not intended for production use—it’s primarily for demonstration purposes. Below, I’ll show you a basic Node service that ties everything together.

const express = require('express');
const { MongoClient, ObjectId } = require('mongodb');
const cors = require('cors'); 

const app = express();
app.use(express.json());
app.use(cors());

const uri = `mongodb://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${process.env.DB_HOSTNAME}:27017`;
const client = new MongoClient(uri, { useNewUrlParser: true, useUnifiedTopology: true });

async function run() {
  try {
    await client.connect();
    const database = client.db('games');
    const gamesCollection = database.collection('game');
    const assetsCollection = database.collection('assets');

    app.get('/games', async (req, res) => {
      try {
        const { isInstalled } = req.query;
        const query = isInstalled ? { isInstalled: isInstalled === 'true' } : {};
        
        const games = await gamesCollection.find(query).toArray();

        const gamesWithAssets = await Promise.all(games.map(async game => {
          const assets = await assetsCollection.find({ relatedId: game.id, typeKey: 'cover' }).toArray();
          game.assets = assets.map(asset => ({
            id: asset.id,
            file: `/assets/${asset.id}`
          }));
          return game;
        }));

        gamesWithAssets.sort((a, b) => {
          if (a.isRunning && !b.isRunning) return -1;
          if (!a.isRunning && b.isRunning) return 1;
          if (a.lastActivity > b.lastActivity) return -1;
          if (a.lastActivity < b.lastActivity) return 1;
          if (a.recentActivity > b.recentActivity) return -1;
          if (a.recentActivity < b.recentActivity) return 1;
          if (a.name < b.name) return -1;
          if (a.name > b.name) return 1;
          return 0;
        });

        res.json(gamesWithAssets);
      } catch (err) {
        res.status(500).json({ error: err.message });
      }
    });

    app.get('/assets/:id', async (req, res) => {
      try {
        const { id } = req.params;
        const asset = await assetsCollection.findOne({ id: id });

        if (!asset) {
          return res.status(404).send('Asset not found');
        }

        const buffer = Buffer.from(asset.file.buffer, 'binary');
        const ext = asset.id.split('.').pop().toLowerCase();

        let contentType = 'application/octet-stream';
        if (ext === 'jpg' || ext === 'jpeg') {
          contentType = 'image/jpeg';
        } else if (ext === 'png') {
          contentType = 'image/png';
        } else if (ext === 'gif') {
          contentType = 'image/gif';
        } 
        res.setHeader('Content-Type', contentType);
        res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
        res.send(buffer);
      } catch (err) {
        res.status(500).json({ error: err.message });
      }
    });

    const PORT = process.env.PORT || 3000;
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  } catch (err) {
    console.error(err);
  }
}

run().catch(console.error);

and Dockerfile to run this:

FROM node:18
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
docker build -t playnite-node-api .
docker run -d --name playnite-node-service -p 3000:3000 \
  -e DB_USERNAME='your_db_username' \
  -e DB_PASSWORD='your_db_password' \
  -e DB_HOSTNAME='your_db_hostname' \
  playnite-node-api

This command first builds the Docker image with the tag playnite-node-api from the Dockerfile in your current directory. Then, it runs the container in detached mode (-d) with the name playnite-node-service, mapping port 3000 of the container to port 3000 on the host. It also sets environment variables for the database credentials and hostname, which are used in the Node.js application to connect to MongoDB.

That’s it! You should now have a basic dashboard set up that can launch your games, manage your apps, and more, all integrated seamlessly with your Home Assistant. This guide provides a solid foundation, empowering you to further customize and expand your system to suit your needs. Enjoy the convenience and control of your new, streamlined gaming environment.

Next Steps

The ultimate aim is to achieve voice control over the entire system. Moving forward, my strategy involves dynamically generating virtual switches for each application within the Playnite library. These switches will then be integrated with Google Home/Assistant, enabling voice commands to manage your gaming and apps directly. The prospect of saying things like “Hey Google, launch The Witcher 3” directly from your couch is just around the corner—I’m excited for what’s to come!