First Commit

This commit is contained in:
Carsten Graf
2026-03-05 14:52:36 +01:00
commit d5353b3c2b
24 changed files with 3893 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Node.js
node_modules/
# Stream Deck files
*.sdPlugin/bin
*.sdPlugin/logs

20
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Attach to Plugin",
"type": "node",
"request": "attach",
"processId": "${command:PickProcess}",
"outFiles": [
"${workspaceFolder}/bin/**/*.js"
],
"resolveSourceMapLocations": [
"${workspaceFolder}/**"
]
}
]
}

17
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
/* JSON schemas */
"json.schemas": [
{
"fileMatch": [
"**/manifest.json"
],
"url": "https://schemas.elgato.com/streamdeck/plugins/manifest.json"
},
{
"fileMatch": [
"**/layouts/*.json"
],
"url": "https://schemas.elgato.com/streamdeck/plugins/layout.json"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -0,0 +1,111 @@
{
"Name": "OctoControll",
"Version": "0.1.0.0",
"Author": "Carsten Graf",
"Actions": [
{
"Name": "Printer Status",
"UUID": "com.octoprint.monitor.printer-status",
"Icon": "imgs/actions/counter/icon",
"Tooltip": "Shows printer and job status from OctoPrint.",
"PropertyInspectorPath": "ui/increment-counter.html",
"Controllers": [
"Keypad"
],
"States": [
{
"Image": "imgs/actions/counter/key",
"TitleAlignment": "middle"
}
]
},
{
"Name": "Pause / Resume Print",
"UUID": "com.octoprint.monitor.print-pause",
"Icon": "imgs/actions/counter/icon",
"Tooltip": "Pause or resume the current OctoPrint job.",
"PropertyInspectorPath": "ui/increment-counter.html",
"Controllers": [
"Keypad"
],
"States": [
{
"Image": "imgs/actions/counter/key",
"TitleAlignment": "middle"
}
]
},
{
"Name": "Cancel Print",
"UUID": "com.octoprint.monitor.print-cancel",
"Icon": "imgs/actions/counter/icon",
"Tooltip": "Cancel the current OctoPrint job (with confirmation).",
"PropertyInspectorPath": "ui/increment-counter.html",
"Controllers": [
"Keypad"
],
"States": [
{
"Image": "imgs/actions/counter/key",
"TitleAlignment": "middle"
}
]
},
{
"Name": "Temperatures",
"UUID": "com.octoprint.monitor.temperature",
"Icon": "imgs/actions/counter/icon",
"Tooltip": "Show hotend and bed temperatures from OctoPrint.",
"PropertyInspectorPath": "ui/increment-counter.html",
"Controllers": [
"Keypad"
],
"States": [
{
"Image": "imgs/actions/counter/key",
"TitleAlignment": "middle"
}
]
},
{
"Name": "Home Axes",
"UUID": "com.octoprint.monitor.home-axes",
"Icon": "imgs/actions/counter/icon",
"Tooltip": "Home all axes on the printer via OctoPrint.",
"PropertyInspectorPath": "ui/increment-counter.html",
"Controllers": [
"Keypad"
],
"States": [
{
"Image": "imgs/actions/counter/key",
"TitleAlignment": "middle"
}
]
}
],
"Category": "OctoControll",
"CategoryIcon": "imgs/plugin/category-icon",
"CodePath": "bin/plugin.js",
"Description": "Controll your Octoprint instance",
"Icon": "imgs/plugin/marketplace",
"SDKVersion": 3,
"Software": {
"MinimumVersion": "6.9"
},
"OS": [
{
"Platform": "mac",
"MinimumVersion": "12"
},
{
"Platform": "windows",
"MinimumVersion": "10"
}
],
"Nodejs": {
"Version": "20",
"Debug": "enabled"
},
"UUID": "com.carsten-graf.octocontroll"
}

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head lang="en">
<title>OctoPrint Connection</title>
<meta charset="utf-8" />
<script src="https://sdpi-components.dev/releases/v4/sdpi-components.js"></script>
</head>
<body>
<sdpi-item label="Host">
<sdpi-textfield setting="host" placeholder="http://octopi.local"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="Port">
<sdpi-textfield setting="port" placeholder="80"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="API Key">
<sdpi-textfield setting="apiKey" placeholder="Paste your OctoPrint API key"></sdpi-textfield>
</sdpi-item>
<sdpi-item label="Show Temp in Status">
<sdpi-checkbox setting="showTemperature">Show temperatures on Printer Status key</sdpi-checkbox>
</sdpi-item>
</body>
</html>

3112
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w --watch.onEnd=\"streamdeck restart com.carsten-graf.octocontroll\""
},
"type": "module",
"devDependencies": {
"@elgato/cli": "^1.7.1",
"@rollup/plugin-commonjs": "^28.0.0",
"@rollup/plugin-node-resolve": "^15.2.2",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.0",
"@tsconfig/node20": "^20.1.2",
"@types/node": "~20.15.0",
"rollup": "^4.0.2",
"tslib": "^2.6.2",
"typescript": "^5.2.2"
},
"dependencies": {
"@elgato/streamdeck": "^2.0.0"
}
}

49
rollup.config.mjs Normal file
View File

@@ -0,0 +1,49 @@
import commonjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";
import path from "node:path";
import url from "node:url";
const isWatching = !!process.env.ROLLUP_WATCH;
const sdPlugin = "com.carsten-graf.octocontroll.sdPlugin";
/**
* @type {import('rollup').RollupOptions}
*/
const config = {
input: "src/plugin.ts",
output: {
file: `${sdPlugin}/bin/plugin.js`,
sourcemap: isWatching,
sourcemapPathTransform: (relativeSourcePath, sourcemapPath) => {
return url.pathToFileURL(path.resolve(path.dirname(sourcemapPath), relativeSourcePath)).href;
}
},
plugins: [
{
name: "watch-externals",
buildStart: function () {
this.addWatchFile(`${sdPlugin}/manifest.json`);
},
},
typescript({
mapRoot: isWatching ? "./" : undefined
}),
nodeResolve({
browser: false,
exportConditions: ["node"],
preferBuiltins: true
}),
commonjs(),
!isWatching && terser(),
{
name: "emit-module-package-file",
generateBundle() {
this.emitFile({ fileName: "package.json", source: `{ "type": "module" }`, type: "asset" });
}
}
]
};
export default config;

43
src/actions/home-axes.ts Normal file
View File

@@ -0,0 +1,43 @@
// src/actions/home-axes.ts
import streamDeck, { action, KeyDownEvent, SingletonAction } from "@elgato/streamdeck";
import { OctoPrintClient, OctoPrintSettings } from "../octoprint-client";
@action({ UUID: "com.octoprint.monitor.home-axes" })
export class HomeAxesAction extends SingletonAction {
override async onKeyDown(ev: KeyDownEvent): Promise<void> {
const settings = ev.payload.settings as unknown as OctoPrintSettings;
if (!settings?.host || !settings?.apiKey) {
await ev.action.setTitle("Configure");
return;
}
const client = new OctoPrintClient(settings);
try {
const printer = await client.getPrinterState();
if (printer.state.flags.printing) {
await ev.action.setTitle("Printing!\nBlocked");
setTimeout(() => ev.action.setTitle("Home"), 2000);
return;
}
if (!printer.state.flags.operational) {
await ev.action.setTitle("Offline");
setTimeout(() => ev.action.setTitle("Home"), 2000);
return;
}
await ev.action.setTitle("Homing…");
await client.homeAxes(["x", "y", "z"]);
await ev.action.setTitle("✓ Homed");
setTimeout(() => ev.action.setTitle("Home"), 2000);
} catch (err) {
streamDeck.logger.error("HomeAxes: failed", err);
await ev.action.setTitle("⚠️ Error");
setTimeout(() => ev.action.setTitle("Home"), 2000);
}
}
}

View File

@@ -0,0 +1,48 @@
// src/actions/print-cancel.ts
import streamDeck, { action, KeyDownEvent, SingletonAction } from "@elgato/streamdeck";
import { OctoPrintClient, OctoPrintSettings } from "../octoprint-client";
@action({ UUID: "com.octoprint.monitor.print-cancel" })
export class PrintCancelAction extends SingletonAction {
// Track confirmation state per action instance
private confirmPending = new Map<string, ReturnType<typeof setTimeout>>();
override async onKeyDown(ev: KeyDownEvent): Promise<void> {
const settings = ev.payload.settings as unknown as OctoPrintSettings;
const actionId = ev.action.id;
if (!settings?.host || !settings?.apiKey) {
await ev.action.setTitle("Configure");
return;
}
// Two-press confirmation to avoid accidental cancels
if (this.confirmPending.has(actionId)) {
// Second press — confirmed, cancel the print
clearTimeout(this.confirmPending.get(actionId)!);
this.confirmPending.delete(actionId);
const client = new OctoPrintClient(settings);
try {
await client.cancelPrint();
await ev.action.setTitle("✓ Cancelled");
setTimeout(() => ev.action.setTitle("Cancel"), 3000);
} catch (err) {
streamDeck.logger.error("PrintCancel: failed", err);
await ev.action.setTitle("⚠️ Error");
setTimeout(() => ev.action.setTitle("Cancel"), 3000);
}
} else {
// First press — ask for confirmation
await ev.action.setTitle("Press\nAgain!");
const timeout = setTimeout(async () => {
this.confirmPending.delete(actionId);
await ev.action.setTitle("Cancel");
}, 3000);
this.confirmPending.set(actionId, timeout);
}
}
}

View File

@@ -0,0 +1,60 @@
// src/actions/print-pause.ts
import streamDeck, { action, KeyDownEvent, SingletonAction, WillAppearEvent } from "@elgato/streamdeck";
import { OctoPrintClient, OctoPrintSettings } from "../octoprint-client";
@action({ UUID: "com.octoprint.monitor.print-pause" })
export class PrintPauseAction extends SingletonAction {
override async onWillAppear(ev: WillAppearEvent): Promise<void> {
await this.updateTitle(ev.action, ev.payload.settings as unknown as OctoPrintSettings);
}
override async onKeyDown(ev: KeyDownEvent): Promise<void> {
const settings = ev.payload.settings as unknown as OctoPrintSettings;
if (!settings?.host || !settings?.apiKey) {
await ev.action.setTitle("Configure");
return;
}
const client = new OctoPrintClient(settings);
try {
const printer = await client.getPrinterState();
if (!printer.state.flags.printing && !printer.state.flags.paused) {
await ev.action.setTitle("Not\nPrinting");
setTimeout(() => ev.action.setTitle("Pause /\nResume"), 2000);
return;
}
await client.pausePrint();
await ev.action.setTitle(printer.state.flags.paused ? "Resuming…" : "Pausing…");
// Refresh title after a moment
setTimeout(() => this.updateTitle(ev.action, settings), 2000);
} catch (err) {
streamDeck.logger.error("PrintPause: failed", err);
await ev.action.setTitle("⚠️ Error");
}
}
private async updateTitle(action: any, settings: OctoPrintSettings): Promise<void> {
if (!settings?.host || !settings?.apiKey) {
await action.setTitle("Configure");
return;
}
try {
const client = new OctoPrintClient(settings);
const printer = await client.getPrinterState();
if (printer.state.flags.paused) {
await action.setTitle("▶ Resume");
} else if (printer.state.flags.printing) {
await action.setTitle("⏸ Pause");
} else {
await action.setTitle("Pause /\nResume");
}
} catch {
await action.setTitle("Pause /\nResume");
}
}
}

View File

@@ -0,0 +1,117 @@
// src/actions/printer-status.ts
import streamDeck, {
action,
DidReceiveSettingsEvent,
KeyDownEvent,
SingletonAction,
WillAppearEvent,
WillDisappearEvent,
} from "@elgato/streamdeck";
import { OctoPrintClient, OctoPrintSettings, formatTime } from "../octoprint-client";
interface PrinterStatusSettings extends OctoPrintSettings {
showTemperature: boolean;
}
@action({ UUID: "com.octoprint.monitor.printer-status" })
export class PrinterStatusAction extends SingletonAction {
private pollingIntervals = new Map<string, ReturnType<typeof setInterval>>();
override async onWillAppear(ev: WillAppearEvent): Promise<void> {
await this.startPolling(ev.action, ev.payload.settings);
}
override async onWillDisappear(ev: WillDisappearEvent): Promise<void> {
this.stopPolling(ev.action.id);
}
override async onDidReceiveSettings(ev: DidReceiveSettingsEvent): Promise<void> {
this.stopPolling(ev.action.id);
await this.startPolling(ev.action, ev.payload.settings);
}
override async onKeyDown(ev: KeyDownEvent): Promise<void> {
// Refresh immediately on key press
await this.updateKey(ev.action, ev.payload.settings);
}
private async startPolling(action: any, rawSettings: any): Promise<void> {
const settings = rawSettings as PrinterStatusSettings;
if (!settings?.host || !settings?.apiKey) {
await action.setTitle("Configure\nPlugin");
return;
}
// Update immediately
await this.updateKey(action, settings);
// Poll every 5 seconds
const interval = setInterval(async () => {
await this.updateKey(action, settings);
}, 5000);
this.pollingIntervals.set(action.id, interval);
}
private stopPolling(actionId: string): void {
const interval = this.pollingIntervals.get(actionId);
if (interval) {
clearInterval(interval);
this.pollingIntervals.delete(actionId);
}
}
private async updateKey(action: any, rawSettings: any): Promise<void> {
const settings = rawSettings as PrinterStatusSettings;
if (!settings?.host || !settings?.apiKey) {
await action.setTitle("Not\nConfigured");
return;
}
const client = new OctoPrintClient(settings);
try {
const [printer, job] = await Promise.all([
client.getPrinterState(),
client.getJobState(),
]);
const stateText = printer.state.text;
const flags = printer.state.flags;
let title = "";
if (flags.printing) {
const pct = job.progress.completion ?? 0;
const timeLeft = formatTime(job.progress.printTimeLeft);
const fileName = job.job.file.name ?? "Unknown";
const shortName = fileName.length > 10 ? fileName.substring(0, 9) + "…" : fileName;
title = `${Math.round(pct)}%\n${timeLeft}\n${shortName}`;
if (settings.showTemperature && printer.temperature?.tool0) {
const temp = Math.round(printer.temperature.tool0.actual);
title = `${Math.round(pct)}%\n${temp}°C\n${timeLeft}`;
}
} else if (flags.paused) {
title = "PAUSED";
} else if (flags.operational) {
title = "Ready";
if (settings.showTemperature && printer.temperature?.tool0) {
const hotend = Math.round(printer.temperature.tool0.actual);
const bed = printer.temperature.bed ? Math.round(printer.temperature.bed.actual) : 0;
title = `Ready\n🔥${hotend}°\n⬛${bed}°`;
}
} else if (flags.closedOrError) {
title = "Offline";
} else {
title = stateText;
}
await action.setTitle(title);
} catch (err) {
await action.setTitle("⚠️ Error");
streamDeck.logger.error("PrinterStatus: failed to fetch", err);
}
}
}

View File

@@ -0,0 +1,92 @@
// src/actions/temperature.ts
import streamDeck, {
action,
DidReceiveSettingsEvent,
KeyDownEvent,
SingletonAction,
WillAppearEvent,
WillDisappearEvent,
} from "@elgato/streamdeck";
import { OctoPrintClient, OctoPrintSettings } from "../octoprint-client";
@action({ UUID: "com.octoprint.monitor.temperature" })
export class TemperatureAction extends SingletonAction {
private pollingIntervals = new Map<string, ReturnType<typeof setInterval>>();
override async onWillAppear(ev: WillAppearEvent): Promise<void> {
await this.startPolling(ev.action, ev.payload.settings);
}
override async onWillDisappear(ev: WillDisappearEvent): Promise<void> {
this.stopPolling(ev.action.id);
}
override async onDidReceiveSettings(ev: DidReceiveSettingsEvent): Promise<void> {
this.stopPolling(ev.action.id);
await this.startPolling(ev.action, ev.payload.settings);
}
override async onKeyDown(ev: KeyDownEvent): Promise<void> {
await this.updateKey(ev.action, ev.payload.settings);
}
private async startPolling(action: any, rawSettings: any): Promise<void> {
const settings = rawSettings as OctoPrintSettings;
if (!settings?.host || !settings?.apiKey) {
await action.setTitle("Configure");
return;
}
await this.updateKey(action, settings);
const interval = setInterval(async () => {
await this.updateKey(action, settings);
}, 5000);
this.pollingIntervals.set(action.id, interval);
}
private stopPolling(actionId: string): void {
const interval = this.pollingIntervals.get(actionId);
if (interval) {
clearInterval(interval);
this.pollingIntervals.delete(actionId);
}
}
private async updateKey(action: any, rawSettings: any): Promise<void> {
const settings = rawSettings as OctoPrintSettings;
if (!settings?.host || !settings?.apiKey) {
await action.setTitle("Configure");
return;
}
const client = new OctoPrintClient(settings);
try {
const printer = await client.getPrinterState();
if (!printer.temperature) {
await action.setTitle("No Temp\nData");
return;
}
const hotend = printer.temperature.tool0;
const bed = printer.temperature.bed;
const hotendStr = hotend
? `🔥${Math.round(hotend.actual)}/${Math.round(hotend.target)}°`
: "🔥 --";
const bedStr = bed
? `${Math.round(bed.actual)}/${Math.round(bed.target)}°`
: "⬛ --";
await action.setTitle(`${hotendStr}\n${bedStr}`);
} catch (err) {
streamDeck.logger.error("Temperature: failed to fetch", err);
await action.setTitle("⚠️ Error");
}
}
}

133
src/octoprint-client.ts Normal file
View File

@@ -0,0 +1,133 @@
// src/octoprint-client.ts
// OctoPrint REST API client
export interface OctoPrintSettings {
host: string; // e.g. "http://octopi.local" or "http://192.168.1.100"
port: string; // e.g. "80" or "5000"
apiKey: string; // OctoPrint API key
}
export interface PrinterState {
state: {
text: string;
flags: {
operational: boolean;
paused: boolean;
printing: boolean;
pausing: boolean;
cancelling: boolean;
sdReady: boolean;
error: boolean;
ready: boolean;
closedOrError: boolean;
};
};
temperature?: {
tool0?: { actual: number; target: number };
bed?: { actual: number; target: number };
};
}
export interface JobState {
job: {
file: { name: string | null };
estimatedPrintTime: number | null;
};
progress: {
completion: number | null;
printTimeLeft: number | null;
printTime: number | null;
};
state: string;
}
export class OctoPrintClient {
private baseUrl: string;
private apiKey: string;
constructor(settings: OctoPrintSettings) {
const port = settings.port ? `:${settings.port}` : "";
this.baseUrl = `${settings.host}${port}/api`;
this.apiKey = settings.apiKey;
}
private get headers(): Record<string, string> {
return {
"X-Api-Key": this.apiKey,
"Content-Type": "application/json",
};
}
async getPrinterState(): Promise<PrinterState> {
const res = await fetch(`${this.baseUrl}/printer`, {
headers: this.headers,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json() as Promise<PrinterState>;
}
async getJobState(): Promise<JobState> {
const res = await fetch(`${this.baseUrl}/job`, {
headers: this.headers,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return res.json() as Promise<JobState>;
}
async pausePrint(): Promise<void> {
await fetch(`${this.baseUrl}/job`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ command: "pause", action: "toggle" }),
});
}
async cancelPrint(): Promise<void> {
await fetch(`${this.baseUrl}/job`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ command: "cancel" }),
});
}
async homeAxes(axes: string[] = ["x", "y", "z"]): Promise<void> {
await fetch(`${this.baseUrl}/printer/printhead`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ command: "home", axes }),
});
}
async setTemperature(tool: "tool0" | "bed", target: number): Promise<void> {
if (tool === "bed") {
await fetch(`${this.baseUrl}/printer/bed`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ command: "target", target }),
});
} else {
await fetch(`${this.baseUrl}/printer/tool`, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ command: "target", targets: { [tool]: target } }),
});
}
}
async testConnection(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/version`, { headers: this.headers });
return res.ok;
} catch {
return false;
}
}
}
export function formatTime(seconds: number | null): string {
if (seconds === null || seconds === undefined) return "--:--";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

18
src/plugin.ts Normal file
View File

@@ -0,0 +1,18 @@
// src/plugin.ts
import streamDeck from "@elgato/streamdeck";
import { PrinterStatusAction } from "./actions/printer-status";
import { PrintPauseAction } from "./actions/print-pause";
import { PrintCancelAction } from "./actions/print-cancel";
import { TemperatureAction } from "./actions/temperature";
import { HomeAxesAction } from "./actions/home-axes";
// Register all actions
streamDeck.actions.registerAction(new PrinterStatusAction() as any);
streamDeck.actions.registerAction(new PrintPauseAction() as any);
streamDeck.actions.registerAction(new PrintCancelAction() as any);
streamDeck.actions.registerAction(new TemperatureAction() as any);
streamDeck.actions.registerAction(new HomeAxesAction() as any);
// Connect to Stream Deck
streamDeck.connect();

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"customConditions": [
"node"
],
"module": "ES2022",
"moduleResolution": "Bundler",
"noImplicitOverride": true
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
]
}