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.
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!