Clone the GitHub Repo and if you want, read some comments below!

As I found out 3D printers makes a lot of noise and a lot of people probably live in an apartment with a +1, so after a couple of days printing 24/7 it was driving my wife nuts so it had to move… in my case the closet. Then I realised that I was running back and forth every 5min (which is undoubtedly good for my health) or constantly checking your apps video feed for an update so I decided to write this little Node app to tell me the current status every 5 or 10th percent, in a fluffy female voice, with a British accent! Just listen to this, I mean…

So, shopping list.

  • RaspberryPi – guess any will do or just your computer…
  • Speaker – any will do
  • Azure/Google Cloud or any other TTS service (Only Azure included for now)
  • 3D Printer with OctoPI (OctoPrint) server running

I have included two different ways of playing the TTS audio, streaming or playing it locally from the PI. You can set this in the .env file “STREAM_AUDIO”. At home I have a main server running and I use REDIS as my message buss so for me I send a request to fetch the audio and then stream the result MP3 back to the client. To save some time and in the long run, some money I decided to hash each TTS request so I can compare the the incoming text if I already have an audio file for it.

So, most of the stuff in there is pretty self explanatory but there were a couple of hiccups that probably other people will find useful as well.

Code

Here is the entry point, it handles logging in to OctoPrint and connecting to the web socket. I’ve also added a Queue for handling the spoken phrases, otherwise it will play all the phrases at once. At the bottom I have an intervall which checks the queue every 0.5 seconds.

"use strict";
require('dotenv').config();
const axios = require('axios');
const { spawn } = require("child_process");

var WebSocketClient = require('websocket').client;
const azureSpeak = require('./services/azure.tts');
const utils = require('./utils');
const octopiStatus = require('./octopi.status');

let octoSession = null;

// Hold all the phrases we will process
const speakQueue = new utils.Queue();
let isSpeaking = false;

// ------ REDIS -----
if (process.env.STREAM_AUDIO == 1) {
    const Redis = require('ioredis');
    const redis = new Redis(process.env.REDIS_SERVER, 6379);
    const redis_publish = new Redis(process.env.REDIS_SERVER, 6379);
    let channel = process.env.REDIS_CHANNEL;

    redis.on('message', (channel, message) => {
            // console.log(`Received the following message from: ${channel}: ${message}`);
            let data = JSON.parse(message);
            if (data.type == "speak") {
                const process = spawn("mpg123", [data.url]);
                process.on('close', function (code) {
                    isSpeaking = false;
                });
                process.on('error', function (err) {
                    isSpeaking = false;
                });
            }
    });

    redis.subscribe(channel, (error, count) => {
        if (error) {
            throw new Error(error);
        }
        console.log(`Subscribed to ${count} channel. Listening for updates on the '${channel}' channel.`);
    });

    module.exports = {
        redis_publish
    }
}

function processSpeakQueue() {
    if (isSpeaking === false && !speakQueue.isEmpty()) {
        let text = speakQueue.dequeue();
        isSpeaking = true;

        if (process.env.STREAM_AUDIO == 1) {
            axios.get(process.env.API_SERVER + '/api/v1/speak', {
                params: {
                    'text': text
                }
            });
        }
        else {
            azureSpeak.speak(text).then((result) => {
                isSpeaking = false;
            })
        }
    }
}

// Create a new Websocket and connect to OctoPi
let client = new WebSocketClient();
client.on('connectFailed', function (error) {
    console.log('Connect Error: ' + error.toString());
});

function speakCallback(text) {
    speakQueue.enqueue(text);
}

client.on('connect', function (connection) {
    // Send our username and session
    connection.send("{\"auth\": \"" + process.env.OCTOPI_USER + ":" + octoSession + "\"}")
    console.log('OctoPI Client Connected');

    speakQueue.enqueue('OctoPI Client Connected!');

    connection.on('error', function (error) {
        console.log("Connection Error: " + error.toString());
    });

    connection.on('close', function () {
        console.log('OctoPI Connection Closed');
    });

    connection.on('message', function (message) {
        if (message.type === 'utf8') {
            let data = JSON.parse(message.utf8Data);
            // let str = JSON.stringify(data, null, 4);
            // console.log(str);

            if (data.current) {
                octopiStatus.processStatus(data, speakCallback);
            }
        }
    });
});

// Here we login to OctoPI to get our session id
axios.post(process.env.OCTOPI_SERVER + '/api/login', {
    user: process.env.OCTOPI_USER,
    pass: process.env.OCTOPI_PASSWORD,
    passive: true
})
    .then(function (response) {
        //console.log(response.data);
        octoSession = response.data.session;
        client.connect(process.env.OCTOPI_SERVER + '/sockjs/websocket');
    });

setInterval(function () { processSpeakQueue() }, 200);

The main logic lives in /src/octopi.status.js. If you want to write your own phrases here is where you will change them.

The basic logic behind it is:

  • Only speak each phrase once
  • If the printed filename changes, reset all the values and start again
  • The “state” variable drives the logic
const utils = require('./utils');

let state = null;
let flags = null;
let progress = null;
let activeFile = null;
let temperature = null;

let firstStatusUpdated = false;
let lastSpokenProgress = 0;
let lastSpokenStatus = null;
let lastActiveFile = null;

function processStatus(data, callback) {
    // console.log(data);

    try { state = (data.current["state"][["text"]]); } catch { }
    try { flags = (data.current["state"]["flags"]); } catch { }
    try { progress = parseInt(data.current["progress"]['completion']); } catch { }
    try { activeFile = (data.current["busyFiles"][0]["path"]); } catch { }
    try { temperature = (data.current["temps"][0]); } catch { }

    if(activeFile) {
        if(activeFile != lastActiveFile)
        {
            lastActiveFile = activeFile;
            lastSpokenStatus = 0;
        }
    }

    let reply = "";
    switch (state) {
        case "Cancelling":
            if (lastSpokenStatus !== "Cancelling") {
                reply = "Cancelling the print";
                lastSpokenStatus = state;
                callback(reply);
            }
            break;
        case "Pausing":
            reply = "Printer is pausing...";
            break;
        case "Operational":
            if (lastSpokenStatus !== "Operational") {
                reply = "Printer is operational.";
                lastSpokenStatus = state;
                callback(reply);
            }
            break;
        case "Paused":
            break;
        case "Printing":
            if(progress == 100) {
                reply = "Your print is done!";
                callback(reply);
            }

            if (!firstStatusUpdated && temperature) {
                reply = "Your print is " + progress + "% complete and the " + checkTemperature();
                callback(reply);
                firstStatusUpdated = true;
            }
            // Only update for every 10th percent progress
            else if ((progress > lastSpokenProgress) && firstStatusUpdated) {
                if (progress % process.env.PROGRESS_UPDATE_EVERY === 0 && temperature) {
                    lastSpokenProgress = progress;
                    reply = "Your print is " + progress + "% complete and the " + checkTemperature();
                    callback(reply);
                }
            }
            break;
        case "Resuming":
            if (lastSpokenStatus !== "Resuming") {
                reply = "Printer is resuming.";
                lastSpokenStatus = state;
                callback(reply);
            }
            break;
        case "SdReady":
            reply = "How you like them apples?";
            break;
        case "Error":
            if (lastSpokenStatus !== "Error") {
                reply = "Something is wrong with the printer, please check.";
                lastSpokenStatus = state;
                callback(reply);
            }
            break;
        case "Ready":
            if (lastSpokenStatus !== "Ready") {
                reply = "Printer is ready!";
                lastSpokenStatus = state;
                callback(reply);
            }
            break;
        case "Finishing":
            if (lastSpokenStatus !== "Finishing") {
                reply = "Printer is finishing up!";
                lastSpokenStatus = state;
                callback(reply);
            }
            break;
        case "ClosedOrError":
            if (lastSpokenStatus !== "ClosedOrError") {
                reply = "Printer has closed or there is an error...";
                lastSpokenStatus = state;
                callback(reply);
            }
            break;

        default:
            console.log("Ehm, this is the default...");
    }
}

function checkTemperature() {
    let bedTarget = temperature["bed"]["target"];
    let bedCurrent = temperature["bed"]["actual"];

    let toolTarget = temperature["tool0"]["actual"];
    let toolCurrent = temperature["tool0"]["actual"];

    let bedOk = false;
    let toolOk = false;

    if (utils.between(bedCurrent, bedTarget - 5.0, bedTarget + 5.0)) {
        bedOk = true;
    }
    else if (!bedOk && state === "Printing") {
        return "bed has a problem, currently " + bedCurrent + " degrees.";
    }

    if (utils.between(toolCurrent, toolTarget - 5.0, toolTarget + 5.0)) {
        toolOk = true;
    }
    else if (!bedOk && state === "Printing") {
        return "tool has a problem, currently " + toolCurrent + " degrees.";
    }

    if (toolOk && bedOk) {
        return ("overall temperature is good!");
    }
}

module.exports = {
    processStatus
}

OctoPrint Websocket connection

It took a couple of tries before I could connect to OctoPIs web socket, probably I didn’t read the documentation fully or missed it but here is the key parts.

First we want to send a login request to get a sessionID from OctoPrint, otherwise you can’t connect to the websocket.

// Here we login to OctoPI to get our session id
axios.post(process.env.OCTOPI_SERVER + '/api/login', {
    user: process.env.OCTOPI_USER,
    pass: process.env.OCTOPI_PASSWORD,
    passive: true
})
.then(function (response) {
    //console.log(response.data);
    octoSession = response.data.session;
    client.connect(process.env.OCTOPI_SERVER + '/sockjs/websocket');
});

Once we have that we can create our web socket connection

// Create a new Websocket and connect to OctoPi
let client = new WebSocketClient();
client.on('connectFailed', function (error) {
    console.log('Connect Error: ' + error.toString());
});

function speakCallback(text) {
    speakQueue.enqueue(text);
}

client.on('connect', function (connection) {
    // Send our username and session
    connection.send("{\"auth\": \"" + process.env.OCTOPI_USER + ":" + octoSession + "\"}")
    console.log('OctoPI Client Connected');

    speakQueue.enqueue('OctoPI Client Connected!');

    connection.on('error', function (error) {
        console.log("Connection Error: " + error.toString());
    });

    connection.on('close', function () {
        console.log('OctoPI Connection Closed');
    });

    connection.on('message', function (message) {
        if (message.type === 'utf8') {
            let data = JSON.parse(message.utf8Data);
            // let str = JSON.stringify(data, null, 4);
            // console.log(str);

            if (data.current) {
                octopiStatus.processStatus(data, speakCallback);
            }
        }
    });
});

Most important line is in the client on connect:

connection.send("{\"auth\": \"" + process.env.OCTOPI_USER + ":" + octoSession + "\"}")

Once we have started up we will send this through the web socket letting it know who we are and with the correct sessionId.

Now all the WS messages should fill the log. If you want you can uncomment the log message to see the full output.

Deploy on Raspberry Pi

I assume you have a PI ready with SSH, NodeJS and Git installed, so we’ll start with connecting to it.

ssh pi@192.168.1.244

Clone the repository

git clone https://github.com/christian-kardach/octopi-status.git
cd octopi-status/

Now create the .env file and open it in nano

touch .env
nano .env

Copy/Paste from the example .env file or just rename the existing one

OCTOPI_SERVER=http://octopi.local
OCTOPI_USER=""
OCTOPI_PASSWORD=""
REDIS_SERVER=""
REDIS_CHANNEL=""
API_SERVER=
AZURE_KEY=""
STREAM_AUDIO=1
PROGRESS_UPDATE_EVERY=10

Fill in your information and press Ctrl+O to save, Ctrl+X to close Nano

Last thing before testing is to install all the modules.

npm install

If everything went ok then you can fire it up and hear the first message.

OctoPi Client Connected, printer is operational

pi@raspberrypi:~/octopi-status $ node src/index.js 
Subscribed to 1 channel. Listening for updates on the 'speak' channel.
OctoPI Client Connected

Boot Pi and start client

You probably want to start the client every time you plug/unplug the PI and I usually go with PM2.

sudo npm install pm2 -g

Then to start it do the following

pi@raspberrypi:~/octopi-status $ pm2 start src/index.js 
[PM2] Starting /home/pi/octopi-status/src/index.js in fork_mode (1 instance)
[PM2] Done.
┌─────┬──────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id  │ name     │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├─────┼──────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0   │ index    │ default     │ 1.0.0   │ fork    │ 3029     │ 0s     │ 0    │ online    │ 0%       │ 21.9mb   │ pi       │ disabled │
└─────┴──────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘

In order for PM2 to start on reboot

pi@raspberrypi:~/octopi-status $ pm2 startup
[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/usr/local/bin /usr/local/lib/node_modules/pm2/bin/pm2 startup systemd -u pi --hp /home/pi

Follow the instructions and execute the command

pi@raspberrypi:~/octopi-status $ sudo env PATH=$PATH:/usr/local/bin /usr/local/lib/node_modules/pm2/bin/pm2 startup systemd -u pi --hp /home/pi
[PM2] Init System found: systemd
Platform systemd
Template
[Unit]
Description=PM2 process manager
[...]

To make sure everything is working reboot the PI and you should hear the voice once done.

pi@raspberrypi:~/octopi-status $ sudo reboot now

Feel free to add more and let me know if you have issues!

Happy printing!