// ======== CONFIG ========
const MOONRAKER_HOST = "192.168.1.160";
const MOONRAKER_PORT = 7125;
const BASE = `http://${MOONRAKER_HOST}:${MOONRAKER_PORT}`;
const POLL_MS = 10_000;
// Ablagepfad in ioBroker
const ROOT = "javascript.0.moonraker.Kossel";
// Optional: Testfunktion (Alexa-Ansage jede Minute)
const ENABLE_TEST_SPEAK = false; // ← bei Bedarf auf true
const ALEXA_SPEAK_DP = "alexa2.0.Echo-Devices.IDANPASSEN.Commands.speak"; // Deine Echo-ID einsetzen
const TEST_MESSAGE = "Moonraker Script Test läuft.";
// ======== HELFER ========
// HTTP GET (Node.js Core)
function httpGet(url, timeout = 5000) {
return new Promise((resolve, reject) => {
const http = require("http");
const req = http.get(url, (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
res.resume();
return;
}
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`JSON parse error for ${url}: ${e.message}`));
}
});
});
req.on("error", reject);
req.setTimeout(timeout, () => {
req.destroy(new Error(`Timeout after ${timeout}ms for ${url}`));
});
});
}
// State-Helper
async function ensureState(id, def) {
if (!existsState(id)) {
await createStateAsync(id, def, { role: def.common?.role || "state" });
}
}
async function writeNum(id, val, unit = "", role = "value") {
await ensureState(id, {
type: "number",
read: true,
write: false,
def: 0,
name: id.split(".").slice(-1)[0],
role,
unit
});
setState(id, { val: Number(val), ack: true });
}
async function writeStr(id, val, role = "text") {
await ensureState(id, {
type: "string",
read: true,
write: false,
def: "",
name: id.split(".").slice(-1)[0],
role
});
setState(id, { val: String(val ?? ""), ack: true });
}
async function writeBool(id, val, role = "indicator") {
await ensureState(id, {
type: "boolean",
read: true,
write: false,
def: false,
name: id.split(".").slice(-1)[0],
role
});
setState(id, { val: !!val, ack: true });
}
// Formatierungen / Konvertierungen
function toPercent01(v) {
if (v == null || isNaN(v)) return null;
return Math.round(Number(v) * 1000) / 10; // 1 Nachkommastelle
}
function toHMS(seconds) {
if (seconds == null || isNaN(seconds)) return "";
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
function round1(v) {
if (v == null || isNaN(v)) return null;
return Math.round(Number(v) * 10) / 10;
}
// Dynamik-Cache für Objekte (Fans, Temp-Sensoren, etc.)
let discovered = {
fans: [],
tempSensors: [],
hasHeaterBed: false,
hasExtruder: false,
hasDisplayStatus: false,
hasGcodeMove: false,
hasVirtualSd: false,
hasToolhead: false,
hasPrintStats: false,
lastDiscoveryTs: 0
};
async function discoverObjects() {
try {
const list = await httpGet(`${BASE}/printer/objects/list`);
const raw = list?.result?.objects;
let names = [];
if (Array.isArray(raw)) {
names = raw
.map((o) => (typeof o === "string" ? o : (o && o.name) ? String(o.name) : ""))
.filter((n) => typeof n === "string" && n.length > 0);
} else if (raw && typeof raw === "object") {
names = Object.keys(raw);
} else {
names = [];
}
const isStr = (n) => typeof n === "string" && n.length > 0;
discovered.fans = names.filter((n) =>
isStr(n) && (n.startsWith("fan") || n.startsWith("heater_fan") || n.startsWith("controller_fan") || n.startsWith("fan_generic"))
);
discovered.tempSensors = names.filter((n) =>
isStr(n) && (n.startsWith("temperature_sensor") || n.startsWith("temperature_fan") || n.startsWith("bme280") || n.startsWith("htu21d") || n.startsWith("lm75"))
);
discovered.hasHeaterBed = names.includes("heater_bed");
discovered.hasExtruder = names.includes("extruder");
discovered.hasDisplayStatus = names.includes("display_status");
discovered.hasGcodeMove = names.includes("gcode_move");
discovered.hasVirtualSd = names.includes("virtual_sdcard");
discovered.hasToolhead = names.includes("toolhead");
discovered.hasPrintStats = names.includes("print_stats");
discovered.lastDiscoveryTs = Date.now();
await writeStr(`${ROOT}.meta.last_discovery`, new Date().toISOString());
await writeNum(`${ROOT}.meta.discovered.fans_count`, discovered.fans.length, "", "value");
await writeNum(`${ROOT}.meta.discovered.temp_sensors_count`, discovered.tempSensors.length, "", "value");
} catch (e) {
log(`Moonraker discovery error: ${e.message}`, "warn");
}
}
function buildQueryParams() {
const objs = ["print_stats"];
if (discovered.hasHeaterBed) objs.push("heater_bed");
if (discovered.hasExtruder) objs.push("extruder");
if (discovered.hasDisplayStatus) objs.push("display_status");
if (discovered.hasGcodeMove) objs.push("gcode_move");
if (discovered.hasVirtualSd) objs.push("virtual_sdcard");
if (discovered.hasToolhead) objs.push("toolhead");
if (discovered.hasPrintStats) objs.push("print_stats");
objs.push(...discovered.fans);
objs.push(...discovered.tempSensors);
const q = objs.map(encodeURIComponent).join("&");
return `${BASE}/printer/objects/query?${q}`;
}
// ======== REST DES POLLERS (pollOnce, Startup, Timer, etc.) ========
// Alexa-Ansage Tracking (Doppelansagen vermeiden)
let lastPrintState = "";
let lastAnnouncedFile = "";
let lastAnnouncedTs = 0;
const ANNOUNCE_COOLDOWN_MS = 5 * 60 * 1000; // 5 Minuten
async function pollOnce() {
try {
// 1) Basisinfos
const [serverInfo, printerInfo] = await Promise.all([
httpGet(`${BASE}/server/info`),
httpGet(`${BASE}/printer/info`)
]);
const s = serverInfo?.result || {};
await writeStr(`${ROOT}.server.version`, s.version || "");
await writeStr(`${ROOT}.server.klippy_state`, s.klippy_state || "");
await writeStr(`${ROOT}.server.hostname`, s.hostname || "");
await writeStr(`${ROOT}.server.websocket_address`, s.websocket_address || "");
const p = printerInfo?.result || {};
await writeStr(`${ROOT}.printer.state`, p.state || "unknown");
await writeBool(`${ROOT}.printer.ready`, p.state === "ready", "indicator.reachable");
// 2) Objekte ggf. alle 5 Min neu entdecken
if (Date.now() - discovered.lastDiscoveryTs > 5 * 60 * 1000 || discovered.lastDiscoveryTs === 0) {
await discoverObjects();
}
// 3) Printer Objects abfragen
const queryUrl = buildQueryParams();
const q = await httpGet(queryUrl);
const res = q?.result?.status || {};
// ---- print_stats ----
let currentFilename = "";
let layerInfo = { current: null, total: null };
let metaFromFile = { layer_height: null, object_height: null };
if (res.print_stats) {
const ps = res.print_stats;
await writeStr(`${ROOT}.print_stats.state`, ps.state || "");
await writeStr(`${ROOT}.print_stats.filename`, ps.filename || "");
await writeStr(`${ROOT}.print_stats.message`, ps.message || "");
currentFilename = ps.filename || "";
// === Alexa-Ansage bei Fertigstellung ===
try {
let shouldAnnounce = false;
if ((ps.state || "") === "complete") {
if (currentFilename) {
if (currentFilename !== lastAnnouncedFile) {
shouldAnnounce = true;
lastAnnouncedFile = currentFilename;
}
} else if (lastPrintState !== "complete" && (Date.now() - lastAnnouncedTs) > ANNOUNCE_COOLDOWN_MS) {
shouldAnnounce = true;
}
}
if (shouldAnnounce) {
const msg = currentFilename ? `Druckauftrag abgeschlossen: ${currentFilename}.` : "Der 3D Druck ist fertig.";
setState(ALEXA_SPEAK_DP, { val: msg, ack: false }); // nutzt deinen Alexa-Datenpunkt
lastAnnouncedTs = Date.now();
await writeStr(`${ROOT}.meta.last_completed_file`, currentFilename || "");
await writeStr(`${ROOT}.meta.last_announcement`, new Date().toISOString());
}
lastPrintState = ps.state || "";
} catch (e) {
log("Alexa-Fertig-Ansage fehlgeschlagen: " + e.message, "warn");
}
if (ps.total_duration != null) await writeStr(`${ROOT}.print_stats.total_duration_hms`, toHMS(ps.total_duration), "time");
if (ps.print_duration != null) await writeStr(`${ROOT}.print_stats.print_duration_hms`, toHMS(ps.print_duration), "time");
if (ps.filament_used != null) {
const m = Math.round((Number(ps.filament_used) || 0) / 10) / 100; // mm → m
await writeNum(`${ROOT}.print_stats.filament_used_m`, m, "m", "value.length");
}
if (ps.progress != null) {
await writeNum(`${ROOT}.print_stats.progress_percent`, toPercent01(ps.progress), "%", "value.percent");
}
// Layer bevorzugt aus print_stats.info (nur vorhanden, wenn Slicer SET_PRINT_STATS_INFO mitschreibt)
const info = ps.info || {};
if (info.current_layer != null) layerInfo.current = Number(info.current_layer);
if (info.total_layer != null) layerInfo.total = Number(info.total_layer);
}
// ---- toolhead (für Z→Layer-Fallback) ----
let zPos = null;
if (res.toolhead) {
const th = res.toolhead;
if (Array.isArray(th.position)) {
const [x, y, z] = th.position;
zPos = (z != null ? Number(z) : null);
await writeNum(`${ROOT}.toolhead.x`, round1(x), "mm", "value.length");
await writeNum(`${ROOT}.toolhead.y`, round1(y), "mm", "value.length");
await writeNum(`${ROOT}.toolhead.z`, round1(z), "mm", "value.length");
}
if (th.max_velocity != null) await writeNum(`${ROOT}.toolhead.max_velocity`, round1(th.max_velocity), "mm/s", "value.speed");
if (th.max_accel != null) await writeNum(`${ROOT}.toolhead.max_accel`, round1(th.max_accel), "mm/s²", "value.acceleration");
if (th.homing_origin && Array.isArray(th.homing_origin)) {
const [hx, hy, hz] = th.homing_origin;
await writeNum(`${ROOT}.toolhead.homing_origin.x`, round1(hx), "mm", "value.length");
await writeNum(`${ROOT}.toolhead.homing_origin.y`, round1(hy), "mm", "value.length");
await writeNum(`${ROOT}.toolhead.homing_origin.z`, round1(hz), "mm", "value.length");
}
if (th.print_time != null) await writeStr(`${ROOT}.toolhead.print_time_hms`, toHMS(th.print_time), "time");
}
// ---- gcode_move ----
if (res.gcode_move) {
const gm = res.gcode_move;
if (gm.speed != null) await writeNum(`${ROOT}.gcode_move.speed`, round1(gm.speed), "mm/s", "value.speed");
if (gm.speed_factor != null) await writeNum(`${ROOT}.gcode_move.speed_factor_percent`, toPercent01(gm.speed_factor), "%", "value.percent");
if (gm.extrude_factor != null) await writeNum(`${ROOT}.gcode_move.extrude_factor_percent`, toPercent01(gm.extrude_factor), "%", "value.percent");
if (Array.isArray(gm.gcode_position)) {
const [x, y, z, e] = gm.gcode_position;
await writeNum(`${ROOT}.gcode_move.gcode_x`, round1(x), "mm", "value.length");
await writeNum(`${ROOT}.gcode_move.gcode_y`, round1(y), "mm", "value.length");
await writeNum(`${ROOT}.gcode_move.gcode_z`, round1(z), "mm", "value.length");
await writeNum(`${ROOT}.gcode_move.gcode_e`, round1(e), "mm", "value.length");
}
}
// ---- heater_bed ----
if (res.heater_bed) {
const hb = res.heater_bed;
if (hb.temperature != null) await writeNum(`${ROOT}.heater_bed.temperature`, round1(hb.temperature), "°C", "value.temperature");
if (hb.target != null) await writeNum(`${ROOT}.heater_bed.target`, round1(hb.target), "°C", "value.temperature");
if (hb.power != null) await writeNum(`${ROOT}.heater_bed.power_percent`, toPercent01(hb.power), "%", "value.percent");
}
// ---- extruder ----
if (res.extruder) {
const ex = res.extruder;
if (ex.temperature != null) await writeNum(`${ROOT}.extruder.temperature`, round1(ex.temperature), "°C", "value.temperature");
if (ex.target != null) await writeNum(`${ROOT}.extruder.target`, round1(ex.target), "°C", "value.temperature");
if (ex.power != null) await writeNum(`${ROOT}.extruder.power_percent`, toPercent01(ex.power), "%", "value.percent");
if (ex.pressure_advance != null) await writeNum(`${ROOT}.extruder.pressure_advance`, round1(ex.pressure_advance), "", "value");
if (ex.smooth_time != null) await writeNum(`${ROOT}.extruder.smooth_time`, round1(ex.smooth_time), "s", "value.interval");
}
// ---- virtual_sdcard ----
if (res.virtual_sdcard) {
const vsd = res.virtual_sdcard;
if (vsd.progress != null) await writeNum(`${ROOT}.virtual_sdcard.progress_percent`, toPercent01(vsd.progress), "%", "value.percent");
if (vsd.file_position != null) await writeNum(`${ROOT}.virtual_sdcard.file_position`, Number(vsd.file_position), "B", "value");
}
// ---- display_status ----
if (res.display_status) {
const ds = res.display_status;
await writeStr(`${ROOT}.display_status.message`, ds.message || "");
if (ds.progress != null) await writeNum(`${ROOT}.display_status.progress_percent`, toPercent01(ds.progress), "%", "value.percent");
}
// ---- Fans ----
for (const f of discovered.fans) {
const key = f;
const safeKey = key.replace(/\s+/g, "_");
const obj = res[key];
if (!obj) continue;
if (obj.speed != null) await writeNum(`${ROOT}.fans.${safeKey}.speed_percent`, toPercent01(obj.speed), "%", "value.percent");
if (obj.rpm != null) await writeNum(`${ROOT}.fans.${safeKey}.rpm`, Number(obj.rpm), "rpm", "value");
}
// ---- Temperatur-Sensoren ----
for (const t of discovered.tempSensors) {
const key = t;
const safeKey = key.replace(/\s+/g, "_");
const obj = res[key];
if (!obj) continue;
if (obj.temperature != null) await writeNum(`${ROOT}.temp_sensors.${safeKey}.temperature`, round1(obj.temperature), "°C", "value.temperature");
if (obj.speed != null) await writeNum(`${ROOT}.temp_sensors.${safeKey}.speed_percent`, toPercent01(obj.speed), "%", "value.percent");
}
// ---- Datei-Metadaten (Layer-Fallback) ----
if ((layerInfo.total == null || layerInfo.current == null) && currentFilename) {
try {
const meta = await httpGet(`${BASE}/server/files/metadata?filename=${encodeURIComponent(currentFilename)}`);
const md = meta?.result || {};
const lh = md?.layer_height ?? md?.slicer?.layer_height ?? null;
const oh = md?.object_height ?? md?.metadata?.object_height ?? null;
if (lh != null) metaFromFile.layer_height = Number(lh);
if (oh != null) metaFromFile.object_height = Number(oh);
if (metaFromFile.layer_height != null) await writeNum(`${ROOT}.file.meta.layer_height_mm`, round1(metaFromFile.layer_height), "mm", "value.length");
if (metaFromFile.object_height != null) await writeNum(`${ROOT}.file.meta.object_height_mm`, round1(metaFromFile.object_height), "mm", "value.length");
if (layerInfo.total == null && metaFromFile.layer_height && metaFromFile.object_height) {
layerInfo.total = Math.max(1, Math.round(metaFromFile.object_height / metaFromFile.layer_height));
}
if (layerInfo.current == null && metaFromFile.layer_height && zPos != null) {
layerInfo.current = Math.max(0, Math.min(layerInfo.total || 999999, Math.floor(zPos / metaFromFile.layer_height)));
}
} catch (e) {
// nicht kritisch
}
}
// ---- Layer in Datenpunkte schreiben ----
if (layerInfo.current != null) await writeNum(`${ROOT}.layers.current`, layerInfo.current, "", "value");
if (layerInfo.total != null) await writeNum(`${ROOT}.layers.total`, layerInfo.total, "", "value");
if (layerInfo.current != null && layerInfo.total != null && layerInfo.total > 0) {
const pct = Math.round((layerInfo.current / layerInfo.total) * 1000) / 10;
await writeNum(`${ROOT}.layers.progress_percent`, pct, "%", "value.percent");
}
await writeStr(`${ROOT}.meta.last_update`, new Date().toISOString(), "date");
await writeBool(`${ROOT}.meta.ok`, true, "indicator.working");
} catch (e) {
await writeBool(`${ROOT}.meta.ok`, false, "indicator.working");
await writeStr(`${ROOT}.meta.error`, e.message || String(e));
log(`Moonraker poll error: ${e.message}`, "warn");
}
}
// ======== STARTUP ========
(async () => {
await ensureState(`${ROOT}.meta.ok`, { type: "boolean", def: false, read: true, write: false, role: "indicator.working" });
await discoverObjects();
pollOnce();
})();
// Intervall
let pollTimer = setInterval(pollOnce, POLL_MS);
// ======== OPTIONALE TESTFUNKTION (Alexa) ========
let testTimer = null;
if (ENABLE_TEST_SPEAK) {
testTimer = setInterval(() => {
try {
setState(ALEXA_SPEAK_DP, { val: TEST_MESSAGE, ack: false });
} catch (e) {
log("Alexa-Testansage fehlgeschlagen: " + e.message, "warn");
}
}, 60_000);
}