Writing Plugins for the Elgato Stream Deck (NodeJS)

Objective in Brief

This article will show you how to write a simple plugin for the Elgato Stream Deck from scratch with NodeJS. It also shows how you can integrate a secondary language into this, for those for whom JS is not their primary language.

Introduction

Although streaming is on my list of things to do in 2021, I purchased my Stream Deck XL a few weeks ago as much to see how i could use it for developing tools than entering into the world of Twitch en all.

A shout out of Chrissy LeMaire and her post My setup after a year of livestreaming which gave me the original impetus to try out one of these button riddled marvels.

Elgato provide a readily accessible SDK for writing plugins for their device and I’ve spent the past week getting to grips with it.

NOTE FOR WINDOWS USERS:
The code below makes use of shell scripts with language specific shebangs. You will need to adjust these to use a suitable equivalent and update the codepath property in manifest.js accordingly. However, the code will remain exactly the same.

Plugin Background

Architecture

Essentially, you write plugins for the Stream Deck App, as it acts as a proxy to the device itself. Indeed, if you do not have the Stream Deck app open, your stream deck won’t operate beyond displaying the screensaver image. The document below illustrates this:

Plugins can utilise native Javascript in HTML documents, or alternatively a compiled command line tool, which can be written in a language of your choice.

Operation

Plugins are represented in the Stream Deck application on the right hand side of the window. The example below is a specific Custom group, including our Catme! plugin.

A plugin can contain one more more actions that can be used. To use one with the Stream Deck, you simply drag the action onto a specific key in the app. At this point, it is now also visible on your stream deck device and you can interact with it accordingly.

Plugins allow you to define the actions that occur, not only when a button is pressed, but also when other activities happen, such as an application you are monitoring is launched.

The Catme! Plugin

I decided that a good use of a plugin for a device costing over two hundred euros would be one that displayed a random cat image each time a button was pressed. Go figure… 🙂

Our Configuration

The basic software and hardware configuration the cat plugin has been developed on consists of:

  • Mac OSX, Big Sur – See note below for Windows systems and shell scripts.
  • PowerShell 7.1.0
  • NodeJS v15.50 (more about this below)
  • Elgato Stream Deck XL

Create New Plugin Directory

Plugins are stored in a specific directory under the generic Stream Deck plugin folder. On OSX systems, this is located by default at $HOME/Library/Application Support/com.elgato.StreamDeck/Plugins.

Making sure the Stream Deck application is closed, create a folder, com.example.timsapp.sdPlugin in the above directory. You’ll probably have noticed that folder names are in reverse DNS name format. This is intended for the purpose that may arise where you want your plugin to be available..

Create a Manifest File

Copy the text below, paste it into your editor of choice and save it in the folder you just renamed, saving the file as manifest.json, overwriting the existing file.

{
  "Actions": [
    {
      "Icon": "actionIcon",
      "Name": "Catme!",
      "States": [
        {
          "Image": "image",
          "TitleAlignment": "middle",
          "FontSize": "16"
        }
      ],
      "SupportedInMultiActions": false,
      "Tooltip": "This is a test application",
      "UUID": "com.example.timsapp.action"
    }
  ],
  "SDKVersion": 2,
  "Author": "Tim",
  "CodePath": "plugin",
  "Description": "This is a test application",
  "Name": "Tims App",
  "URL": "http://52.212.255.70",
  "Version": "1.4",
  "OS": [
    {
      "Platform": "mac",
      "MinimumVersion": "10.11"
    },
    {
      "Platform": "windows",
      "MinimumVersion": "10"
    }
  ],
  "Software": {
    "MinimumVersion": "4.1"
  }
}

You can find full documentation on the manifest file, manifest.json, here. However, the most important property to note at this time, with regards to the above, is “CodePath”. This refers to the filename of the plugins custom program that will be executed when the Stream Deck app starts up. Note that it’s location is relative to the directory of the plugin.

Process Initialisation Data

External plugins are invoked on loading of the Stream Deck app. The file specified in the CodePath property of manifest.json is launched (plugin in our case), passing argument parameters that are required for the intial registration of the plugin. This makes them readily accessible in any language that can process shell level arguments, such as PowerShell (via the $args special variable).

Here is an example of the format of data that gets sent on every initialisation.

-port
28196
-pluginUUID
77E0F22FBDF149124519C52E62BE4D2E
-registerEvent
registerPlugin
-info
{"application":{"language":"en","platform":"mac","version":"4.9.2.13193"},"devicePixelRatio":2,"devices":[{"id":"B32CC8E223C16EAB84570A06958A4860","name":"Stream Deck XL","size":{"columns":8,"rows":4},"type":2}],"plugin":{"version":"1.4"}}

You can find more specifics in the SDK documentation, but for the next stage, plugin registration, you will just need the port, pluginUUID, and registerEvent values.

Plugin Registration

After passing the initial details above, the Stream Deck app then handles the rest of plugin interactions exclusively via async communication over a websocket (including plugin registration). In addition to the creation of a websocket, the client plugin also needs to register handlers for the websocket events, so that interactions with the deck can be processed.

PowerShell and WebSockets Strife

The initial choice i wanted to do was to write the add-on completely in PowerShell. I had started out with it and successfully been able to obtain the arguments from the streamdeck app.

Unfortunately, the many hours I spent after trying to find a simple way to do the triumvirtate of websockets, asynchronous tasks and callbacks in PowerShell, my desire to keep it as simple as possible and minimise technical debt, led me away from using it in its entirety.

Instead, I went for an approach that still includes PowerShell in the mix, using NodeJS to handle registration, and any communication over the websocket, with the rest left to PowerShell to do. I’ve barely touched Javscript, but was able to put together something prettty quickly.

Create the NodeJS Plugin

We’re going to first ensure that two necessary packages are installed and available for our plug.

  • ws
  • shelljs

Then, we’ll create the file containing code which carries out the following:

  • Loads in the modules we installed above
  • Assign variables to the applicable elements in the arguments array.
  • Creates a websocket
  • Defines code for the ‘Open’ event being triggered to register the plugin
  • Defines code for the ‘Message’ event being triggered, detecting if the action was keyDown
  • If the action was keyDown, we fire off the PowerShell script, catme
  • Once the above is finished, start the setImage function, which instructs the app to reset the key image to that as defined in the manifest.

First, start a terminal session, and enter the following to change to the new plugin folder and install two packages we need. Leave your terminal session open.

cd "$HOME/Library/Application Support/com.elgato.StreamDeck/Plugins/com.example.timsapp.sdPlugin"
npm install ws
npm install shelljs

Next, start up your text editor of choice, and paste the following text in, saving it as a plugin

#! /usr/local/bin/node

var WS = require('ws');
var shell = require('shelljs');
var myArgs = process.argv.slice(2);
var port = myArgs[1];
var uuid = myArgs[3];
var registerEvent = myArgs[5];
var ws = new WS('ws://127.0.0.1:' + port);

var DestinationEnum = Object.freeze({
    HARDWARE_AND_SOFTWARE: 0,
    HARDWARE_ONLY: 1,
    SOFTWARE_ONLY: 2
});

function setImage(context, data) {
    var json = {
        event: 'setImage',
        context: context,
        payload: {
            image: data,
            target: DestinationEnum.HARDWARE_AND_SOFTWARE,
            state: 0
        }
    };
    ws.send(JSON.stringify(json));
}

ws.on('open', function () {
    var json = {
        event: registerEvent,
        uuid: uuid
    };
    ws.send(JSON.stringify(json));
});

ws.on('message', function (evt) {
    var jsonObj = JSON.parse(evt);
    if (jsonObj.event && jsonObj.event === 'keyDown') {
        shell.exec('./catme');
        setImage(jsonObj.context, null);
    }
});

Mark the plugin file as executable

Next up, we need change the file plugin so that the operating system sees it as an executable.
Going back to the terminal you left open, enter the following

chmod plugin +x

Register with thecatapi.com

You’ll need an api key in order to connect to the api service of thecatapi.

  • In your browser, go to https://thecatapi.com and signup
  • In due course you will have an api key that can be used in the following script

Create the PowerShell Script

I still wanted to show how simple it is to introduce PowerShell into the equation for your Elgato plugins. In this case, I’m performing the actions that connect to thecatapi.com and obtain a random image.

  • Again, go to your text editor of choice, and paste the code below.
  • Paste your api key between the quotes for x-api-key.
  • Save the file as catme
#! /usr/local/bin/pwsh
$headers = @{
  'x-api-key' = '<< insert your api key here>>'
}

$uri = 'https://api.thecatapi.com/v1/images/search'
$resp = Invoke-RestMethod -Uri $uri -Headers $headers

Invoke-WebRequest -Uri $resp.url -Headers $headers -OutFile ./image.png

Code Summary

  • We defined the header for the webrequest, containing the api key to be used for accessing the web service
  • The webrequest is executed and the output save/overwritten as image.png

Mark the powershell file as executable

Next up, we need change the file plugin so that the operating system sees it as an executable.
Going back to the terminal you left open, enter the following

chmod catme +x

Add an Instance of Catme!

We’re nearly there. All that’s left for us to do now is to add an instance of the plugin onto the deck.

Open the Stream Deck app, expand Custom on the right hand side of the window, and drag Catme! to a spare location on your deck.

At this point, we have carried out all the steps for making a working plugin.

Cat Away!

Now, you’re free to see as many pics of cats as long as you want. Just keep hitting that button. 🙂

You’ll notice on first run that the image on the tile is just a blue background with the question mark. This is simply because no image file exists at that point. Once you start pressing away, but images will appear. As an extra benefit, the last image you had on your deck before you quit the stream deck app will be the one you see appear on reload.

Conclusion

In this blog, we’ve gone through the following process of creating a real barebones plugin for the Elgato Deck. Although not necessary (all of the code can be done in NodeJS), we’ve shown how a secondary language can be used simply

We’ve :

  • Created a simple NodeJS script container handlers for the websocket connection and events
  • Demonstrated how to create a websocket and process its events easily.
  • Shown how we can launch secondary scripts from our plugin.
  • Written a PowerShell script which pulls down a cat image from the web and saves/overwrites it as image.png in the plugin directory.
Share

Writing PowerShell Core AWS Lambda Functions – Part V

Overview

In this fifth and final blog, we’re going to package and publish our recently completed PowerShell Lambda function. We’ll configure an IAM role to allowing publishing, reconfigure our Lex ‘bot to point to the Lambda, and give it a spin from Messenger on a couple of devices. 🙂

NOTE: All of the code over the five blogs is available at my github repository.

The Story So Far…

At this point, we have in place connectivity between our Facebook app and Lex, have written our PowerShell Lambda function, and tested it locally.

Preparation

It’s important to bear in mind that processing of both AWS Tools for PowerShell and AWS Lambda PS Core cmdlets requires a set of AWS credentials (unless the command explicitly does not access AWS resources). These are used to sign the associated request, validating the authenticity of the source. Several methods exist for passing of credentials in requests. These vary from per command to as-default and may be assumed or specified.

The following steps will require one of these methods mentioned to be in place. For reference information on getting credentials setup for use with PowerShell, consult the Using AWS Credentials section of the AWS Tools for PowerShell documentation.

Publish the Package

Navigate to your project’s directory, and enter the following, choosing values for -Name and -ScriptPath that match your requirements.

In my case, the package will be called Get-Synopsis, and the source script file, GetSynopsis.ps1 is located within the GetSynopsis sub-directory.

Publish-AWSPowerShellLambda -Name Get-Synopsis -ScriptPath ./GetSynopsis/GetSynopsis.ps1

Shortly after, when the .zip file has been created of the package, you will be prompted to choose an IAM role for the Lambda function. Note that it is possible to specify the role via one of the cmdlet parameters, but we’re doing this manually to get a feel for what is being done.

  • Choose ### Create new IAM Role ###
  • At the Enter name of the new IAM Role prompt, enter aws-lambda-lex

Then you’ll be asked to chose an IAM Policy that will be attached to the new role.

Select the option that corresponds to AWSLambdaBasicExecutionRole (Provides write permissions to CloudWatch Logs.)

The process will then continue and soon after display a message confirming that the Lambda function has been created.

Verify IAM

Let’s check and see what’s been created in IAM.

  • Go to the main AWS console screen
  • Click Services
  • Click IAM
  • Click Roles


You should see a new role, called aws-lambda-lex. Click on it.


It will have the policy we specified, AWSLambdaBasicExecutionRole, assigned to it.

Check the Lambda

Let’s check the Lambda console section to validate that our package is setup.

  • Go to the main AWS console screen
  • Click Services
  • Click Lambda
  • Check the list on the right hand side for Get-Synopsis


Click Get-Synopsis to have a look at the configuration

Configure Lex

Now, we need to change the Lex configuration so that it triggers the Lambda function.

  • Click Services
  • Click Amazon Lex
  • Click MyPowerShellHelpBot
  • Go to the Fulfillment section
  • For Lambda function, select Get-Synopsis
  • For Version or alias, ensure it is set to Latest


If you are prompted to give permission to allow Lex to invoke Get-Synopsis, click OK

  • Click Save Intent
  • Click Build
  • After confirmation that the bot has built, the test window will open
  • Enter I want help with Get-EC2Host and press return.

  • Looks good! Click Publish
  • Select Prod from the drop down list
  • Click Publish
  • Click Close once the publishing is complete

Test from Messenger

Let’s test this both from our system and also phone.

  • If you need the link to the Messenger page, do the following:
    • Go to https://developers.facebook.com/
    • Click My Apps
    • Click AWS PowerShell Help
    • Under Products click Messenger
    • Click Settings
    • Scroll down to App Review for Messenger and click Page Settings at the bottom of it
    • In the Your Messenger Link, click Copy Link
  • Go to your web browser, and type/paste into the address bar the URL for the Messenger page.
  • Enter the same text as above, I want help with Get-EC2Host
  • You should see output similar to below:

  • Now go to your mobile device
  • Access Messenger, using either the link from above, or the client itself. If using the latter, you will also need to create a new message and enter the page name in the To: section.
  • Enter I want help with Get-S3Object
  • You should see output similar to the following (note that you’ll also see the request you have just done on the other system):

Conclusion

Congratulations! You’ve reached the end of this blog series on writing PowerShell Core Lambda functions and now have an interactive, cross platform, mobile help facility for the AWS PowerShell cmdlets available.

Keep an eye out for forthcoming blogs about PowerShell Core Lambda functions.

Thanks for reading! Feedback welcome!

Share

Writing PowerShell Core AWS Lambda Functions – Part IV

Overview

In this fourth blog, we’re going to write the PowerShell Lambda function. By the end of this blog, we’ll have tested our function, and be ready to package and upload it to AWS.

The Story So Far…

At this point, we have in place connectivity between our Facebook app and Lex and we’ve done a basic test to ensure that our commands are being parsed.

We’re ready to begin development of our PowerShell Lambda function! 🙂

The Goal

When Lex receives data from Messenger that matches an Utterance pattern, it invokes a PowerShell Lambda function we will later write. When the script runs it obtains the value of the command slot, and then obtains a synopsis of what the cmdlet does. This information is returned back to Lex which in turn sends it back to Messenger for it to display.

Writing the Lambda Function

Let’s set about getting our PowerShell script in place.

Create the package

  • Launch PowerShell
  • Type New-AWSPowerShellLambda -Template Basic -ScriptName GetAWSPowerShellHelp
  • A directory will be created, GetAWSPowerShellHelp, containing two files, GetAWSPowerShellHelp.ps1 and readme.txt
  • Open GetAWSPowerShellHelp.ps1 in your editor of choice.
  • Copy the script below into the clipboard
  • Paste into your editor, removing any existing text
  • Save the file
# PowerShell script file to be executed as a AWS Lambda function. 
# 
# When executing in Lambda the following variables will be predefined.
#   $LambdaInput - A PSObject that contains the Lambda function input data.
#   $LambdaContext - An Amazon.Lambda.Core.ILambdaContext object that contains information about the currently running Lambda environment.
#
# The last item in the PowerShell pipeline will be returned as the result of the Lambda function.
#
# To include PowerShell modules with your Lambda function, like the AWSPowerShell.NetCore module, add a "#Requires" statement 
# indicating the module and version.

#Requires -Modules @{ModuleName='AWSPowerShell.NetCore';ModuleVersion='3.3.270.0'}

#Log the event data to CloudWatch
Write-Host ($LambdaInput | ConvertTo-Json)

$ErrorActionPreference = 'Stop'

try {
    #Get the value of the Command slot
    $commandparam = $LambdaInput.currentIntent.slots.Command
    #Get the correct casing for the command
    $command = (get-command -Name $commandparam | Select-Object -ExpandProperty Name)
    $url = "https://docs.aws.amazon.com/powershell/latest/reference/items/$($command).html"
    $pattern = "<div class=`"synopsis`">(?'synopsis'.*)</div>"
    $response = Invoke-WebRequest -Uri $url
    $response.Content -match $pattern
    If (!($description = $Matches["synopsis"])) {
        $description = "No help is available for $command" 
    }
}
Catch [System.Management.Automation.CommandNotFoundException] {$description = "Sorry, no AWS PowerShell cmdlet called $commandparam exists"}
Catch [System.Net.WebException] {$description = "Sorry, no help exists for $command"}

# Response template for Lex
$template = @"
{
	"sessionAttributes": {},
	"dialogAction": {
		"type": "Close",
		"fulfillmentState": "Fulfilled",
		"message": {
			"contentType": "PlainText",
			"content": "$description"
		}
	}
}
"@

Return $template

Script Breakdown

Let’s take a look at the script in more detail:

Define Module Requirements

#Requires -Modules @{ModuleName='AWSPowerShell.NetCore';ModuleVersion='3.3.270.0'}

The Requires definition restricts the script from running unless AWSPowerShell.Netcore, version 3.3.270.0, is available.

Write the Event Data to Cloudwatch

Write-Host ($LambdaInput | ConvertTo-Json)

The contents of the Lambda functions input data variable, $LamdaInput are converted from its object format to JSON, before being output. Use of Write-Host in a PowerShell Lambda function results in the data being written to Cloudwatch. This results in an entry in the log similar to below:

Set PowerShell Error Handling

The default value for the $ErrorActionPreference variable for PowerShell running in a Lambda function is Continue. Unfortunately, it appears within a Lambda function, if an error occurs during execution of a PowerShell script, this causes it to immediately exit and return an error to the caller. In our code there exists two possibilities for errors to be raised. To address this we set $ErrorActionPreference to Stop, and then use a Try..Catch block to handle errors and prevent exit. The specifics of our Try..Catch block are details next.

$ErrorActionPreference = 'Stop'
try {
        .
        .
}
catch {
        .
        .
}

Process the Command Slot

As mentioned in an earlier blog in this series, input data is automatically read and cast into a PSobject called $LambdaInput. Using the sample JSON above as a guide, we can obtain the slot value for Command via $LambdaInput.currentIntent.slots.Command

An additional step we want to do is obtain the correct casing of the command. This is required because we will be querying a URL soon which will include the name of the command, and most web servers are case sensitive.

    $commandparam = $LambdaInput.currentIntent.slots.Command
    #Get the correct casing for the command
    $command = (get-command -Name $commandparam | Select-Object -ExpandProperty Name)

Obtain Online Help Information

A normal approach to getting help about a PowerShell command is via the Get-Help cmdlet. The AWSPowerShell.Netcore module implementation for PowerShell Lambda does not include help information. This is understandable, naturally, with Get-Help being an interactive command. So for us to obtain the synopsis information for a cmdlet we need to grab it from the online help.

Help is available from the AWS Tools for PowerShell Cmdlet Reference page for all cmdlets. For a specific one, this is available at:

https://docs.aws.amazon.com/powershell/latest/reference/items/cmdlet name.html

An example is below:

Knowing this, it is pretty straightforward to calculate the URL that we’ll be accessing:

 $url = "https://docs.aws.amazon.com/powershell/latest/reference/items/$($command).html"

For any given cmdlet’s page, we want to grab the Synopsis information. A look at the page source for the above one shows us this section:

We can use regex with a capture group to grab the text we need. I’ve opted to use the expression below after playing around a bit with the regex tester at Regex Storm. Note the use of a named capture group, synopsis, in our regex.

This is represented in PowerShell (with the addition of backticks to escape the double quotes) via:

$pattern = "<div class=`"synopsis`">(?'synopsis'.*)</div>"

Now we can proceed with scraping the synopsis information.

    $response = Invoke-WebRequest -Uri $url
    $response.Content -match $pattern

We get the content of the web page, then perform the regex match against it.

PowerShell uses a special variable, $matches, which is automatically populated if a successful match occurs. Because we used a named capture group in our expression, synopsis, a hashtable entry with this name is created.

This makes referencing the information simple.

We also want to ensure that if no match is found a message returned to indicate this.

    If (!($description = $Matches["synopsis"])) {
        $description = "No help is available for $command" 
    }

The $description variable is set to the the value of the match. If this is null, however, it is updated to say that no help is available for the cmdlet.

Apply Catch Conditions

We need to apply the Catch conditions to react to errors that can occur within the Try block, since it could be that the :

  • cmdlet does not actually exist
  • cmdlet exists but there is no help webpage
Catch [System.Management.Automation.CommandNotFoundException] {$description = "Sorry, no AWS PowerShell cmdlet called $commandparam exists"}
Catch [System.Net.WebException] {$description = "Sorry, no help exists for $commandparam"}

These are handled by the first and second Catch statements respectively.

Create the Response

We then make use of a template JSON format, using the documentation here for information.

# Response template for Lex
$template = @"
{
	"sessionAttributes": {},
	"dialogAction": {
		"type": "Close",
		"fulfillmentState": "Fulfilled",
		"message": {
			"contentType": "PlainText",
			"content": "$description"
		}
	}
}
"@

These settings indicates that the workflow should end and that the result be marked as successful. The $description variable in the here-string is automatically expanded by PowerShell to contain the value set in the previous steps.

Return the Response

Lastly, the $template string is returned from the PowerShell Lambda function. This will be received by Lex.

Return $template

Test

At this point, you can perform a test locally on your system just by making a couple of changes to your script.

  • Surround the lines from #Requires up to and including $commandparam = $LambdaInput.currentIntent.slots.Command in a remark block
  • Add your own definition after for $commandparam

Now you can run the script locally and receive console output.

Remember to undo those changes mentioned once you have completed testing the script.

Conclusion

At this point, we have written our PowerShell Lambda function and tested it locally. We’re nearly there!

In the next blog in the series, we’ll publish the function to Lambda, configure Lex to invoke it, and show it in operation from a couple of devices.

Thanks for reading! Feedback welcome!

Share