RWOL from Alexa

At the end of 2023, I wrote about how I had used a Raspberry Pi Zero to act as a machine to remotely turn on and off a media server that I had in my house. The idea being that it was unnecessary to have it on 24/7, consuming electricity when I was asleep for much of that time.

While I had a nice little web app from which I could trigger the start up and shut down I always wanted to be able to do it using voice commands via Alexa. Today I finally got round to doing that and I have described how I did it below. While this, for me, is about starting and stopping a server the instructions below will work for anything where the control is via an API with an exposed url end-point.

Creating the Alexa Skill

1. Set Up an Alexa Developer Account

For this you need an Amazon Developer account which is different to an AWS account, although you’ll need one of those too.

2. Create a New Alexa Skill

  1. Go to the Alexa Skills Kit dashboard and click Create Skill.
  2. On the first page:
    • Skill Name: e.g., “Server Control”.
    • Default Language: Your preferred language (e.g., English UK).
  3. Click Next and then:
    • Type of Experience: Smart Home
    • Model: Custom
    • Hosting Service: Provision your own
  4. Click Next
    • Choose “Start from Scratch”
  5. Review and the click Create Skill.

3. Define Intents

In this step we add the phrases that Alexa will recognise when you invoke the command.

  1. From the left hand menu go to the Interaction Model > Intents tab and create two intents:
    • TurnOnServerIntent
    • TurnOffServerIntent
  2. Add sample utterances for each intent:
TurnOnServerIntent:

turn on server
start the server
enable server

TurnOffServerIntent:

turn off server
stop the server
disable server

Create a Function to call the end point

Next, we need to create a Lambda function that Alexa call’s that turns on or off your server. This is done by a call to an API endpoint that you need to update in the supplied function below.

4. Set Up Lambda Function

  1. Go to AWS Lambda and create a new function.
    • Choose Author from Scratch.
    • Set:
      • Function Name: e.g., ServerControlLambda.
      • Runtime: Python.
    • Assign permissions to access the Lambda function.
  2. Write the Lambda function to handle your intents. Example in Python:
import json
import requests

def lambda_handler(event, context):
    if event["request"]["type"] == "LaunchRequest":
        return respond("Welcome to Server Control.")

    if event["request"]["type"] == "IntentRequest":
        intent_name = event["request"]["intent"]["name"]

        if intent_name == "TurnOnServerIntent":
            return handle_server_command("http://example.com/start", "turning on the server")
        elif intent_name == "TurnOffServerIntent":
            return handle_server_command("http://example.com/stop", "turning off the server")

def handle_server_command(url, action_message):
    try:
        response = requests.get(url)
        if response.status_code == 200:
            return respond(f"Success {action_message}. Server responded: {response.text}.")
        else:
            return respond(f"Failed to complete the action. Server responded with status {response.status_code}.")
    except Exception as e:
        return respond(f"An error occurred while {action_message}. Error: {str(e)}.")

def respond(output):
    return {
        "version": "1.0",
        "response": {
            "outputSpeech": {
                "type": "PlainText",
                "text": output
            },
            "shouldEndSession": True
        }
    }
  1. Deploy the Lambda function and copy the ARN.

5. Connect the Skill to Lambda

Now return to the Alexa Developer Console where you will link the Skill with your newly created Lambda function.

  1. Go to Endpoint under the Build tab.
  2. Select AWS Lambda ARN and paste the ARN of your Lambda function.
  3. Save the endpoint configuration.

6. Test Your Skill

  1. Go to the Test tab in the Alexa Developer Console.
  2. Use the Alexa Simulator to test your commands:
    • Say “Turn on server.”
    • Say “Turn off server.”
  3. Verify Alexa announces the result based on the server’s response.

If all has gone well then at this point your skill should be functioning in test. If you have any issues head over to AWS CloudWatch and take a look at the logs for your Lambda function under Log Groups and make sure it is working correctly.

Restricting Access

Now that you have your Alexa skill working the final step is to lock it down so that it is only accessible to you and not the whole world.

7. Restrict Access

By default, your skill will be marked as being “In Dev” which will mean that the skill is only available for your own account, and this is probably what you want unless you are looking to make it more widely available, which is beyond the scope of this post.

However, the API endpoint itself is open and so should be protected which we are going to do once more with Cloudflare Zero Trust.

8. Secure API Endpoint

We are going to use Service Tokens to secure the endpoint and pass these in the Lambda function headers when calling the web link. It goes without saying that the domain must be registered with Cloudflare and the traffic routed via them for this to work. You can do this with a free account.

  1. Head to Cloudflare, login and go to Cloudflare Access
  2. You need an existing application which you can setup as described here: Cloudflare Zero Trust
  3. Select Service Auth from the left hand menu
  4. Click Create service token
  5. Enter a name and a duration and make a note of the header details
  6. Attach a profile to you app allowing access with this service token.

Now return to AWS Lambda as we need to include the custom headers CF-Access-Client-Id and CF-Access-Client-Secret when making a requests.get call. Here’s how you can update your Lambda function code to include these headers:

import json
import requests

# Replace these with your actual values
CF_ACCESS_CLIENT_ID = "<CLIENT_ID>"
CF_ACCESS_CLIENT_SECRET = "<CLIENT_SECRET>"

def lambda_handler(event, context):
    if event["request"]["type"] == "LaunchRequest":
        return respond("Welcome to Server Control.")

    if event["request"]["type"] == "IntentRequest":
        intent_name = event["request"]["intent"]["name"]

        if intent_name == "TurnOnServerIntent":
            return handle_server_command("https://app.example.com/start", "turning on the server")
        elif intent_name == "TurnOffServerIntent":
            return handle_server_command("https://app.example.com/stop", "turning off the server")

def handle_server_command(url, action_message):
    headers = {
        "CF-Access-Client-Id": CF_ACCESS_CLIENT_ID,
        "CF-Access-Client-Secret": CF_ACCESS_CLIENT_SECRET
    }

    try:
        response = requests.get(url, headers=headers)
        if response.status_code == 200:
            return respond(f"Success {action_message}. Server responded: {response.text}.")
        else:
            return respond(f"Failed to complete the action. Server responded with status {response.status_code}.")
    except Exception as e:
        return respond(f"An error occurred while {action_message}. Error: {str(e)}.")

def respond(output):
    return {
        "version": "1.0",
        "response": {
            "outputSpeech": {
                "type": "PlainText",
                "text": output
            },
            "shouldEndSession": True
        }
    }

Save and deploy your function and that’s you done!

Leave a Reply

Your email address will not be published. Required fields are marked *