From 3bf6eef0430fd0e62df37c97f3228448e9dea5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Sun, 4 Oct 2026 18:44:19 +0200 Subject: [PATCH 1/2] feat: add a Moonraker backend for Klipper printers with Mainsail or Fluidd --- AGENTS.md | 21 +- README.md | 33 +- .../Backends/IPrinterBackend.cs | 2 + .../Backends/Moonraker/MoonrakerBackend.cs | 28 ++ .../Backends/Moonraker/MoonrakerConnection.cs | 469 ++++++++++++++++++ .../Backends/Moonraker/MoonrakerHeaters.cs | 26 + .../Backends/Moonraker/MoonrakerHttp.cs | 64 +++ .../Backends/Moonraker/MoonrakerRpc.cs | 238 +++++++++ .../Backends/Moonraker/MoonrakerSetup.cs | 76 +++ .../Backends/Moonraker/MoonrakerStatus.cs | 121 +++++ .../Backends/Moonraker/MoonrakerUrls.cs | 12 + src/PrinterControl/Core/HeaterIds.cs | 2 +- src/PrinterControl/Core/PrinterModels.cs | 2 +- src/PrinterControl/Localization/Strings.resx | 30 ++ src/PrinterControl/manifest.json | 2 +- .../Backends/BackendContract.cs | 2 + .../Backends/MoonrakerTests.cs | 229 +++++++++ .../Support/FakeMoonraker.cs | 294 +++++++++++ 18 files changed, 1637 insertions(+), 14 deletions(-) create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerBackend.cs create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerConnection.cs create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerHeaters.cs create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerHttp.cs create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerRpc.cs create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerSetup.cs create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerStatus.cs create mode 100644 src/PrinterControl/Backends/Moonraker/MoonrakerUrls.cs create mode 100644 tests/PrinterControl.Tests/Backends/MoonrakerTests.cs create mode 100644 tests/PrinterControl.Tests/Support/FakeMoonraker.cs diff --git a/AGENTS.md b/AGENTS.md index c6f5171..eb99bd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,8 +3,8 @@ **Printer Control** is a Macro Deck 3 out-of-process plugin (Windows, macOS and Linux) that connects Macro Deck to 3D printers through the software that runs them: live printer state as variables and events, printer control as actions, a print status widget, and the printer webcam as a video stream (SDK -3.0.0-beta.15). Each kind of printer software is a backend; OctoPrint is the only one shipped. A Moonraker -backend waits on the `feature/moonraker` branch until it has been tested on a real Klipper printer. +3.0.0-beta.15). Each kind of printer software is a backend: OctoPrint, and Moonraker on this branch, which +merges once it has been tested on a real Klipper printer. [README.md](README.md) is the user-facing guide; this file is the rule set for changing the code. Keep it current when a rule stops matching reality. @@ -26,6 +26,8 @@ src/PrinterControl/ IPrinterSetup.cs a backend's own setup steps after the address (signing in) OctoPrint/ REST client, push frame parsing, sign-in (OctoPrintSetup: application keys or a pasted key), OctoPrintConnection, GPIO Control outputs + Moonraker/ JSON-RPC websocket client, Klipper object state (MoonrakerStatus), setup + (trusted client or API key), MoonrakerConnection, power device outputs ConfigFlow/ PrinterConfigFlow (name, address and webcam, recognizes the printer software, then runs its IPrinterSetup), ConfigKeys, PrinterConfigReader Actions/ PrinterAction (printer picker, error mapping, ConfirmAsync) + actions @@ -36,6 +38,7 @@ src/PrinterControl/ tests/PrinterControl.Tests/ Support/FakeOctoPrint.cs a Kestrel fake of OctoPrint's REST API, application keys, GPIO Control and push socket + Support/FakeMoonraker.cs a Kestrel fake of Moonraker's HTTP info endpoints and JSON-RPC websocket Support/IFakePrinterServer.cs what the backend contract needs from a fake, FakeServerHost, NotAPrinterServer Backends/BackendContract.cs the tests every backend passes against its fake; AllFakeServers Backends/Example/ a minimal polling backend, its fake and contract tests: the template @@ -97,6 +100,20 @@ backend type leaks into actions, variables, events or the UI. - OctoPrint reports a cancel as `PrintCancelled` and again as `PrintFailed` with reason `cancelled`; only the first becomes `print-cancelled` (`PushMessages.ReadEvent`). +**Moonraker.** Written against the documented API (https://moonraker.readthedocs.io), not yet tried on +a real printer. + +- Everything after setup goes over the websocket (`MoonrakerRpc`); the API key is in the handshake. + Klippy's state comes from `server.info` and the `notify_klippy_*` notifications, not from the `webhooks` + object, which Moonraker documents as unreliable for that. While Klippy is ready, the printer objects come + from a subscription; `notify_status_update` only carries changed fields (`MoonrakerStatus.Merge`). +- `printer.gcode.script` answers only once the G-code ran, so G-code waits 1 s for a refusal and then + answers sent (`CallWithoutWaitingAsync`); a homing move would otherwise time out. +- Klipper has no job events: they follow from `print_stats.state` changing (`MoonrakerConnection.PrintEvent`). +- Heaters: `extruder`, `extruder1`, `heater_bed`, and a chamber as `heater_generic chamber` (settable) or + `temperature_sensor chamber` (read only) (`MoonrakerHeaters`). +- Power devices have no documented change notification, so they are read every 2 s while there are any. + **Actions.** - Every action takes the `printer` dynamic choice first; empty means the only printer. diff --git a/README.md b/README.md index 01d8965..e9261f9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Printer Control talks to the software that runs your printer. Supported today: | Printer software | Notes | | --- | --- | | [OctoPrint](https://octoprint.org) | Everything below. Lights need the GPIO Control plugin. | +| [Moonraker](https://moonraker.readthedocs.io) (Klipper with Mainsail or Fluidd) | Everything below except connecting and disconnecting the printer, which Klipper does not do. Temperature presets are not read. Lights are Moonraker's power devices. *Start* and *Restart* print the last loaded file again, and *Print a file* can only start a print, not just load the file. | The plugin is built so more printer software can be added; see [Adding printer software](#adding-printer-software). This is an unofficial community plugin, not made or @@ -22,14 +23,21 @@ Any number of printers can be set up, each with its own server. 1. Open the Printer Control integration in Macro Deck and add a printer. 2. Enter the address you open your printer's web interface at, for example `http://octopi.local`. Printer Control recognizes the printer software there by itself. -3. Choose **Approve in OctoPrint**. OctoPrint shows an "Access Request" dialog for Macro Deck in its web - interface; choose **Allow** there, then continue in Macro Deck. If the application keys plugin is - turned off in your OctoPrint, choose **Paste an API key** instead and create a key in OctoPrint under - *Settings > Application Keys*. - -Leave the name empty to use the printer's own name, the name set in OctoPrint (*Settings > Appearance*). -The advanced settings of the address step take a different webcam stream; those of OctoPrint's sign-in -step limit the approval to one OctoPrint user. +3. Sign in, depending on the printer software: + - **OctoPrint:** choose **Approve in OctoPrint**. OctoPrint shows an "Access Request" dialog for Macro + Deck in its web interface; choose **Allow** there, then continue in Macro Deck. If the application + keys plugin is turned off in your OctoPrint, choose **Paste an API key** instead and create a key in + OctoPrint under *Settings > Application Keys*. + - **Moonraker:** nothing to do when this computer is one of Moonraker's `trusted_clients` (in + `moonraker.conf`, often your whole local network). Otherwise paste Moonraker's API key, which + `curl http://localhost:7125/access/api_key` shows on the printer's host. + +Leave the name empty to use the printer's own name: the name set in OctoPrint (*Settings > Appearance*), +or the host name of the machine running Klipper. The advanced settings of the address step take a +different webcam stream; those of OctoPrint's sign-in step limit the approval to one OctoPrint user. + +Moonraker's webcam is the first enabled MJPEG webcam it lists; other kinds of streams (WebRTC, HLS) +cannot be shown, so enter an MJPEG stream address in the advanced settings for those. ## Widgets @@ -115,7 +123,8 @@ widget and anywhere else Macro Deck shows video. ## Privacy The plugin only talks to the printer servers you set up. While you add a printer, it asks the address you -entered which printer software runs there (`/api/version`), without credentials. After that: +entered which printer software runs there (`/api/version` for OctoPrint, `/server/info` for Moonraker), +without credentials. For OctoPrint: - the OctoPrint REST API (`/api/...`) to read settings and files and to send your commands, - OctoPrint's push socket (`/sockjs/websocket`) for live state, @@ -123,6 +132,12 @@ entered which printer software runs there (`/api/version`), without credentials. - the GPIO Control plugin (`/api/plugin/gpiocontrol`), every 2 seconds while OctoPrint lists GPIO outputs, and when you use the switch output action. +For Moonraker: + +- `/server/info` and `/printer/info` while you add the printer, +- Moonraker's websocket (`/websocket`) for everything else: live state, files, webcams, your commands, and + its power devices every 2 seconds while it has any. + The webcam is loaded by Macro Deck itself from the webcam address and relayed to your deck devices; it does not pass through the plugin. The API key is stored in Macro Deck's encrypted secret store. The plugin writes no files of its own and sends nothing anywhere else. diff --git a/src/PrinterControl/Backends/IPrinterBackend.cs b/src/PrinterControl/Backends/IPrinterBackend.cs index 6ce2b6f..ba93d40 100644 --- a/src/PrinterControl/Backends/IPrinterBackend.cs +++ b/src/PrinterControl/Backends/IPrinterBackend.cs @@ -1,5 +1,6 @@ using MacroDeck.Localization; using Microsoft.Extensions.DependencyInjection; +using PrinterControl.Backends.Moonraker; using PrinterControl.Backends.OctoPrint; using PrinterControl.Core; using Serilog; @@ -37,6 +38,7 @@ internal static class PrinterBackendRegistration public static IServiceCollection AddPrinterBackends(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerBackend.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerBackend.cs new file mode 100644 index 0000000..f3db901 --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerBackend.cs @@ -0,0 +1,28 @@ +using MacroDeck.Localization; +using PrinterControl.Core; +using Serilog; + +namespace PrinterControl.Backends.Moonraker; + +// Klipper's API server behind Mainsail and Fluidd. +// https://moonraker.readthedocs.io/en/latest/external_api/introduction/ +internal sealed class MoonrakerBackend(IHttpClientFactory httpClients) : IPrinterBackend +{ + public const string BackendId = "moonraker"; + + public string Id => BackendId; + + public LocalizedText Name => LocalizedText.FromLiteral("Moonraker (Mainsail, Fluidd)"); + + // A trusted client needs no key. + public bool RequiresApiKey => false; + + public async Task DetectAsync(Uri baseUri, CancellationToken cancellationToken) => + MoonrakerHttp.IsMoonraker(await Http(baseUri).GetAsync("server/info", null, cancellationToken)); + + public IPrinterSetup CreateSetup(Uri baseUri, bool editing) => new MoonrakerSetup(Http(baseUri), editing); + + public PrinterConnection Connect(PrinterConfig config, HttpClient http, ILogger logger) => new MoonrakerConnection(config, logger); + + private MoonrakerHttp Http(Uri baseUri) => new(httpClients.CreateClient(PrinterRegistry.HttpClientName), baseUri); +} diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerConnection.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerConnection.cs new file mode 100644 index 0000000..9db72eb --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerConnection.cs @@ -0,0 +1,469 @@ +using System.Globalization; +using System.Text.Json; +using System.Threading.Channels; +using PrinterControl.Core; +using Serilog; + +namespace PrinterControl.Backends.Moonraker; + +// Klippy's state comes from server.info and notify_klippy_*, the printer objects from a subscription +// while it is ready. Power devices are polled, since Moonraker announces no change of theirs. +internal sealed class MoonrakerConnection(PrinterConfig config, ILogger logger) + : PrinterConnection(config, logger), IJobControl, IFileControl, ITemperatureControl, IMotionControl, ISystemCommands, IOutputControl +{ + private const string ClientName = "Macro Deck Printer Control"; + private static readonly TimeSpan StartupPoll = TimeSpan.FromSeconds(2); + private static readonly TimeSpan PowerPollInterval = TimeSpan.FromSeconds(2); + private static readonly TimeSpan GcodeRefusalWindow = TimeSpan.FromSeconds(1); + + private static readonly string[] SystemCommandIds = ["klipper/firmware-restart", "klipper/restart", "moonraker/restart", "host/reboot", "host/shutdown"]; + + private readonly Lock _gate = new(); + private readonly MoonrakerStatus _status = new(); + private readonly Channel _webcamsChanged = Channel.CreateBounded(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite }); + private MoonrakerRpc? _rpc; + private string _klippy = "disconnected"; + private string? _klippyMessage; + private string? _printState; + private IReadOnlyCollection _printerObjects = []; + private TaskCompletionSource _klippyChanged = new(TaskCreationOptions.RunContinuationsAsynchronously); + + private MoonrakerRpc Rpc => Volatile.Read(ref _rpc) + ?? throw new PrinterException(PrinterFailure.Unreachable, $"{Config.DisplayName} is not connected."); + + protected override async Task RunSessionAsync(CancellationToken cancellationToken) + { + await using var rpc = await MoonrakerRpc.ConnectAsync(Config.BaseUri, Config.ApiKey, OnNotification, cancellationToken); + Volatile.Write(ref _rpc, rpc); + try + { + await rpc.CallAsync( + "server.connection.identify", + new { client_name = ClientName, version = "1.0", type = "other", url = "https://github.com/PyFlat/Printer-Control" }, + cancellationToken); + await RefreshSettingsAsync(rpc, cancellationToken); + ReportConnected(); + + using var session = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var power = PollPowerAsync(session.Token); + var webcams = FollowWebcamsAsync(rpc, session.Token); + try + { + await FollowKlippyAsync(rpc, cancellationToken); + } + finally + { + await session.CancelAsync(); + await power.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + await webcams.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + finally + { + Volatile.Write(ref _rpc, null); + lock (_gate) + { + _status.Clear(); + _klippy = "disconnected"; + _printState = null; + } + } + } + + // Returns when the websocket closes. + private async Task FollowKlippyAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + while (true) + { + var changed = ResetKlippySignal(); + var info = await rpc.CallAsync("server.info", null, cancellationToken); + var state = info.Str("klippy_state") ?? "disconnected"; + if (state == "ready") + { + await SubscribeAsync(rpc, cancellationToken); + } + else + { + var message = state is "shutdown" or "error" ? await KlippyMessageAsync(rpc, cancellationToken) : null; + Publish(() => + { + _klippy = state; + _klippyMessage = message; + _printState = null; + }); + } + + // Outside "ready" Moonraker announces only some transitions, so server.info is asked again. + var next = await Task.WhenAny(changed, rpc.Completion, Task.Delay(state == "ready" ? Timeout.InfiniteTimeSpan : StartupPoll, cancellationToken)); + if (next == rpc.Completion) + { + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + } + } + + private async Task SubscribeAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + var listed = await rpc.CallAsync("printer.objects.list", null, cancellationToken); + var available = listed.Arr("objects")?.EnumerateArray().Select(o => o.GetString()).OfType().ToHashSet(StringComparer.Ordinal) + ?? []; + + var objects = new Dictionary(StringComparer.Ordinal) + { + ["print_stats"] = ["state", "filename", "print_duration", "message"], + ["virtual_sdcard"] = ["progress"], + ["gcode_move"] = ["gcode_position"], + }; + foreach (var heater in available.Where(o => MoonrakerHeaters.ToHeaterId(o) is not null)) + { + objects[heater] = ["temperature", "target"]; + } + + var subscribed = await rpc.CallAsync( + "printer.objects.subscribe", + new { objects = objects.Where(o => available.Contains(o.Key)).ToDictionary() }, + cancellationToken); + + Publish(() => + { + _printerObjects = available; + _status.Clear(); + _status.Merge(subscribed.Obj("status") ?? default); + _klippy = "ready"; + _klippyMessage = null; + _printState = _status.PrintState; + }); + } + + private static async Task KlippyMessageAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + try + { + return (await rpc.CallAsync("printer.info", null, cancellationToken)).Str("state_message"); + } + catch (PrinterException) + { + return null; + } + } + + private void OnNotification(string method, JsonElement parameters) + { + switch (method) + { + case "notify_status_update" when parameters.ValueKind == JsonValueKind.Array && parameters.GetArrayLength() > 0: + var diff = parameters[0]; + Publish(() => _status.Merge(diff)); + break; + case "notify_klippy_ready": + Klippy(PrinterEventKind.Connected, "klippy_ready", null); + break; + case "notify_klippy_shutdown": + Klippy(PrinterEventKind.Error, "klippy_shutdown", "shutdown"); + break; + case "notify_klippy_disconnected": + Klippy(PrinterEventKind.Disconnected, "klippy_disconnected", "disconnected"); + break; + case "notify_webcams_changed": + _webcamsChanged.Writer.TryWrite(true); + break; + } + } + + // Notifications arrive on the socket's receive loop, which a call made from there would wait on, so + // the settings are read again here. + private async Task FollowWebcamsAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + while (await _webcamsChanged.Reader.WaitToReadAsync(cancellationToken)) + { + _webcamsChanged.Reader.TryRead(out _); + try + { + await RefreshSettingsAsync(rpc, cancellationToken); + } + catch (PrinterException) + { + } + } + } + + private void Klippy(PrinterEventKind kind, string type, string? state) + { + if (state is not null) + { + Publish(() => + { + _klippy = state; + _printState = null; + }); + } + + PublishEvent(new PrinterEvent(type, default) { Kind = kind }); + Volatile.Read(ref _klippyChanged).TrySetResult(); + } + + private Task ResetKlippySignal() + { + var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Volatile.Write(ref _klippyChanged, signal); + return signal.Task; + } + + // Applies a change under the lock, then publishes the snapshot and the print events it implies + // outside it. A first subscription sets the print state without claiming a transition. + private void Publish(Action change) + { + PrinterSnapshot snapshot; + PrinterEvent? printEvent = null; + lock (_gate) + { + var before = _printState; + change(); + snapshot = _status.ToSnapshot(_klippy, _klippyMessage); + if (_klippy == "ready" && _status.PrintState is { } now && now != before) + { + _printState = now; + if (before is not null) + { + printEvent = PrintEvent(before, now, snapshot); + } + } + } + + PublishSnapshot(snapshot); + if (printEvent is not null) + { + PublishEvent(printEvent); + } + } + + // Klipper has no job events; they follow from print_stats.state changing. + private PrinterEvent? PrintEvent(string before, string now, PrinterSnapshot snapshot) + { + var kind = now switch + { + "printing" => before == "paused" ? PrinterEventKind.PrintResumed : PrinterEventKind.PrintStarted, + "paused" => PrinterEventKind.PrintPaused, + "complete" => PrinterEventKind.PrintDone, + "cancelled" => PrinterEventKind.PrintCancelled, + "error" => PrinterEventKind.PrintFailed, + _ => (PrinterEventKind?)null, + }; + if (kind is null) + { + return null; + } + + var file = _status.FileName; + var duration = snapshot.PrintTime; + var reason = kind == PrinterEventKind.PrintFailed ? snapshot.Error : null; + return new PrinterEvent(now, JsonSerializer.SerializeToElement(new { filename = file, print_duration = duration, message = reason })) + { + Kind = kind, + File = file, + Duration = kind is PrinterEventKind.PrintDone or PrinterEventKind.PrintCancelled or PrinterEventKind.PrintFailed ? duration : null, + Reason = reason, + }; + } + + private async Task RefreshSettingsAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + string? hostname = null; + try + { + hostname = (await rpc.CallAsync("printer.info", null, cancellationToken)).Str("hostname"); + } + catch (PrinterException) + { + } + + PublishSettings(new PrinterSettings( + hostname, + await ReadWebcamAsync(rpc, cancellationToken), + [], + await ReadOutputsQuietlyAsync(rpc, cancellationToken))); + } + + // The first enabled MJPEG webcam; streams of other kinds cannot be relayed. + private static async Task ReadWebcamAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + JsonElement list; + try + { + list = await rpc.CallAsync("server.webcams.list", null, cancellationToken); + } + catch (PrinterException) + { + return WebcamSettings.None; + } + + foreach (var webcam in list.Arr("webcams")?.EnumerateArray() ?? default) + { + if (webcam.Bool("enabled") == false || webcam.Str("service")?.Contains("mjpeg", StringComparison.OrdinalIgnoreCase) != true) + { + continue; + } + + return new WebcamSettings( + true, + webcam.Str("stream_url"), + webcam.Str("snapshot_url"), + webcam.Bool("flip_horizontal") ?? false, + webcam.Bool("flip_vertical") ?? false, + webcam.Num("rotation") is { } rotation ? (int)rotation : 0, + webcam.Str("aspect_ratio")); + } + + return WebcamSettings.None; + } + + public Task RunJobCommandAsync(JobCommand command, CancellationToken cancellationToken) => command switch + { + JobCommand.Pause => Rpc.CallAsync("printer.print.pause", null, cancellationToken), + JobCommand.Resume => Rpc.CallAsync("printer.print.resume", null, cancellationToken), + JobCommand.Cancel => Rpc.CallAsync("printer.print.cancel", null, cancellationToken), + JobCommand.Start or JobCommand.Restart => StartAsync(LoadedFile(), cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(command)), + }; + + // Klipper keeps the last printed file loaded; "start" and "restart" print it (again). + private string LoadedFile() + { + lock (_gate) + { + return _status.FileName ?? throw new PrinterException(PrinterFailure.Conflict, $"{Config.DisplayName} has no file loaded."); + } + } + + private Task StartAsync(string file, CancellationToken cancellationToken) => + Rpc.CallAsync("printer.print.start", new { filename = file }, cancellationToken); + + public async Task> GetFilesAsync(CancellationToken cancellationToken) + { + var files = await Rpc.CallAsync("server.files.list", new { root = "gcodes" }, cancellationToken); + return files.ValueKind != JsonValueKind.Array + ? [] + : [.. files.EnumerateArray() + .Where(f => f.Str("path") is { Length: > 0 }) + .Select(f => new PrintFile( + f.Str("path")!, + f.Str("path")!, + f.Num("size") is { } size ? (long)size : null, + f.Num("modified") is { } modified ? DateTimeOffset.FromUnixTimeMilliseconds((long)(modified * 1000)) : null))]; + } + + // Moonraker loads a file only by printing it. + public Task SelectFileAsync(string fileId, bool print, CancellationToken cancellationToken) => + print ? StartAsync(fileId, cancellationToken) : throw NotSupported("loading a file without printing it"); + + public Task SetTemperatureAsync(string heater, double target, CancellationToken cancellationToken) + { + IReadOnlyCollection objects; + lock (_gate) + { + objects = _printerObjects; + } + + var klipperHeater = MoonrakerHeaters.ToKlipperHeater(heater, objects) ?? throw NotSupported($"setting the {heater} temperature"); + return SendGcodeAsync( + [string.Create(CultureInfo.InvariantCulture, $"SET_HEATER_TEMPERATURE HEATER={klipperHeater} TARGET={target:0.#}")], + cancellationToken); + } + + // Moonraker answers a script only once it ran, which takes as long as the moves do; a refusal + // (an unknown command, unhomed axes) arrives at once. + public Task SendGcodeAsync(IReadOnlyList commands, CancellationToken cancellationToken) => + Rpc.CallWithoutWaitingAsync("printer.gcode.script", new { script = string.Join('\n', commands) }, GcodeRefusalWindow, cancellationToken); + + public Task> GetSystemCommandsAsync(CancellationToken cancellationToken) => + Task.FromResult>( + [ + new(SystemCommandIds[0], Strings.Moonraker.Commands.FirmwareRestart(), true), + new(SystemCommandIds[1], Strings.Moonraker.Commands.RestartKlipper(), true), + new(SystemCommandIds[2], Strings.Moonraker.Commands.RestartMoonraker(), true), + new(SystemCommandIds[3], Strings.Moonraker.Commands.Reboot(), true), + new(SystemCommandIds[4], Strings.Moonraker.Commands.Shutdown(), true), + ]); + + public Task RunSystemCommandAsync(string commandId, CancellationToken cancellationToken) => commandId switch + { + "klipper/firmware-restart" => Rpc.CallAsync("printer.firmware_restart", null, cancellationToken), + "klipper/restart" => Rpc.CallAsync("printer.restart", null, cancellationToken), + "moonraker/restart" => Rpc.CallAsync("machine.services.restart", new { service = "moonraker" }, cancellationToken), + "host/reboot" => Rpc.CallAsync("machine.reboot", null, cancellationToken), + "host/shutdown" => Rpc.CallAsync("machine.shutdown", null, cancellationToken), + _ => throw new PrinterException(PrinterFailure.NotFound, $"{commandId} is not a Moonraker system command."), + }; + + // Outputs are Moonraker's [power ] devices. + public async Task> GetOutputsAsync(CancellationToken cancellationToken) => + [.. (await ReadDevicesAsync(Rpc, cancellationToken)).Select(d => d.Output)]; + + public async Task> ReadOutputsAsync(CancellationToken cancellationToken) + { + var states = (await ReadDevicesAsync(Rpc, cancellationToken)) + .Where(d => d.Status is "on" or "off") + .ToDictionary(d => d.Output.Id, d => d.Status == "on", StringComparer.Ordinal); + PublishOutputStates(states); + return states; + } + + public async Task SetOutputAsync(string outputId, bool on, CancellationToken cancellationToken) + { + var device = (await ReadDevicesAsync(Rpc, cancellationToken)).FirstOrDefault(d => d.Output.Id == outputId) + ?? throw new PrinterException(PrinterFailure.NotFound, $"No power device {outputId}."); + await Rpc.CallAsync("machine.device_power.post_device", new { device = device.Name, action = on ? "on" : "off" }, cancellationToken); + } + + private sealed record PowerDevice(string Name, string Status, PrinterOutput Output); + + private static async Task> ReadDevicesAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + var result = await rpc.CallAsync("machine.device_power.devices", null, cancellationToken); + return [.. (result.Arr("devices")?.EnumerateArray() ?? default) + .Where(d => d.Str("device") is { Length: > 0 }) + .Select(d => new PowerDevice(d.Str("device")!, d.Str("status") ?? string.Empty, new PrinterOutput(OutputId(d.Str("device")!), d.Str("device")!)))]; + } + + private static async Task> ReadOutputsQuietlyAsync(MoonrakerRpc rpc, CancellationToken cancellationToken) + { + try + { + return [.. (await ReadDevicesAsync(rpc, cancellationToken)).Select(d => d.Output)]; + } + catch (PrinterException) + { + return []; + } + } + + // An output id is a local id fragment ([a-z0-9-]); the device name is what Moonraker addresses. + private static string OutputId(string device) + { + var id = new string([.. device.ToLowerInvariant().Select(c => char.IsAsciiLetterOrDigit(c) ? c : '-')]).Trim('-'); + return "power-" + (id.Length == 0 ? "device" : id); + } + + private async Task PollPowerAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + if (Settings.Outputs.Count > 0) + { + try + { + await ReadOutputsAsync(cancellationToken); + } + catch (PrinterException exception) + { + Logger.Debug("Reading the power devices of {Printer} failed: {Message}", Config.DisplayName, exception.Message); + PublishOutputStates(new Dictionary()); + } + } + + await Task.Delay(PowerPollInterval, cancellationToken); + } + } +} diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerHeaters.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerHeaters.cs new file mode 100644 index 0000000..91ebdf6 --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerHeaters.cs @@ -0,0 +1,26 @@ +using PrinterControl.Core; + +namespace PrinterControl.Backends.Moonraker; + +// Klipper's heater objects: "extruder", "extruder1", "heater_bed", and a chamber as +// "heater_generic chamber" (settable) or "temperature_sensor chamber" (read only). +internal static class MoonrakerHeaters +{ + public static string? ToHeaterId(string printerObject) => printerObject switch + { + "extruder" => HeaterIds.Extruder, + "heater_bed" => HeaterIds.Bed, + "heater_generic chamber" or "temperature_sensor chamber" => HeaterIds.Chamber, + _ when printerObject.StartsWith("extruder", StringComparison.Ordinal) + && int.TryParse(printerObject.AsSpan("extruder".Length), out var index) && index > 0 => printerObject, + _ => null, + }; + + // The HEATER name SET_HEATER_TEMPERATURE takes, or null when the printer has no such heater to set. + public static string? ToKlipperHeater(string heater, IReadOnlyCollection printerObjects) => heater switch + { + HeaterIds.Bed => printerObjects.Contains("heater_bed") ? "heater_bed" : null, + HeaterIds.Chamber => printerObjects.Contains("heater_generic chamber") ? "chamber" : null, + _ => printerObjects.Contains(heater) && ToHeaterId(heater) is not null ? heater : null, + }; +} diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerHttp.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerHttp.cs new file mode 100644 index 0000000..ecd4e78 --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerHttp.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Text.Json; +using PrinterControl.Core; + +namespace PrinterControl.Backends.Moonraker; + +// The few plain HTTP calls of detection and setup; the connection itself uses the websocket. +internal sealed class MoonrakerHttp(HttpClient http, Uri baseUri) +{ + public sealed record Answer(HttpStatusCode Status, JsonElement? Json) + { + public JsonElement? Result => Json?.TryGetProperty("result", out var result) == true ? result : null; + } + + public async Task GetAsync(string path, string? apiKey, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(PrinterUrls.Normalize(baseUri), path)); + if (!string.IsNullOrEmpty(apiKey)) + { + request.Headers.Add("X-Api-Key", apiKey); + } + + HttpResponseMessage response; + try + { + response = await http.SendAsync(request, cancellationToken); + } + catch (HttpRequestException exception) + { + throw new PrinterException(PrinterFailure.Unreachable, $"Could not reach {baseUri}: {exception.Message}", exception); + } + catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new PrinterException(PrinterFailure.Unreachable, $"{baseUri} did not answer in time.", exception); + } + + using (response) + { + if (response.Content.Headers.ContentType?.MediaType != "application/json") + { + return new Answer(response.StatusCode, null); + } + + try + { + using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + return new Answer(response.StatusCode, json.RootElement.Clone()); + } + catch (JsonException) + { + return new Answer(response.StatusCode, null); + } + } + } + + // Moonraker answers /server/info with its version, or, without access, with its own error object + // ({"error": {"code": 401, "message": ...}}). OctoPrint and plain web servers answer neither. + public static bool IsMoonraker(Answer info) => info.Status switch + { + HttpStatusCode.OK => info.Result?.Str("moonraker_version") is not null, + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => info.Json?.Obj("error")?.Num("code") is not null, + _ => false, + }; +} diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerRpc.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerRpc.cs new file mode 100644 index 0000000..1c3db62 --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerRpc.cs @@ -0,0 +1,238 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using PrinterControl.Core; + +namespace PrinterControl.Backends.Moonraker; + +// JSON-RPC 2.0 over /websocket. The API key goes into the handshake, which authenticates the whole socket. +internal sealed class MoonrakerRpc : IAsyncDisposable +{ + private static readonly TimeSpan CallTimeout = TimeSpan.FromSeconds(10); + + // Bounds what a misbehaving server can make the plugin buffer; a large file list still fits. + private const int MaxMessageBytes = 16 * 1024 * 1024; + + private readonly ClientWebSocket _socket; + private readonly Action _onNotification; + private readonly ConcurrentDictionary> _pending = new(); + private readonly SemaphoreSlim _sending = new(1, 1); + private readonly CancellationTokenSource _stop = new(); + private int _nextId; + + private MoonrakerRpc(ClientWebSocket socket, Action onNotification) + { + _socket = socket; + _onNotification = onNotification; + Completion = Task.Run(() => ReceiveAsync(_stop.Token)); + } + + // Ends when the socket closes; every call still waiting then fails as unreachable. + public Task Completion { get; } + + public static async Task ConnectAsync( + Uri baseUri, + string? apiKey, + Action onNotification, + CancellationToken cancellationToken) + { + var socket = new ClientWebSocket(); + socket.Options.KeepAliveInterval = TimeSpan.FromSeconds(15); + socket.Options.KeepAliveTimeout = TimeSpan.FromSeconds(30); + if (!string.IsNullOrEmpty(apiKey)) + { + socket.Options.SetRequestHeader("X-Api-Key", apiKey); + } + + try + { + await socket.ConnectAsync(MoonrakerUrls.Websocket(baseUri), cancellationToken); + } + catch (WebSocketException exception) + { + socket.Dispose(); + throw new PrinterException(PrinterFailure.Unreachable, $"Could not open Moonraker's websocket at {baseUri}: {exception.Message}", exception); + } + + return new MoonrakerRpc(socket, onNotification); + } + + public async Task CallAsync(string method, object? parameters, CancellationToken cancellationToken) + { + var (id, answer) = await SendAsync(method, parameters, cancellationToken); + try + { + return await answer.WaitAsync(CallTimeout, cancellationToken); + } + catch (TimeoutException exception) + { + throw new PrinterException(PrinterFailure.Unreachable, $"Moonraker did not answer {method} in time.", exception); + } + finally + { + _pending.TryRemove(id, out _); + } + } + + // For calls Moonraker only answers once the work is done (G-code): waits briefly for a refusal, then + // leaves the call running. A late answer is dropped. + public async Task CallWithoutWaitingAsync(string method, object? parameters, TimeSpan refusalWindow, CancellationToken cancellationToken) + { + var (id, answer) = await SendAsync(method, parameters, cancellationToken); + try + { + await answer.WaitAsync(refusalWindow, cancellationToken); + } + catch (TimeoutException) + { + } + finally + { + _pending.TryRemove(id, out _); + } + } + + private async Task<(int Id, Task Answer)> SendAsync(string method, object? parameters, CancellationToken cancellationToken) + { + var id = Interlocked.Increment(ref _nextId); + var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pending[id] = answer; + + var request = new JsonObject { ["jsonrpc"] = "2.0", ["method"] = method, ["id"] = id }; + if (parameters is not null) + { + request["params"] = JsonSerializer.SerializeToNode(parameters); + } + + await _sending.WaitAsync(cancellationToken); + try + { + await _socket.SendAsync(Encoding.UTF8.GetBytes(request.ToJsonString()), WebSocketMessageType.Text, true, cancellationToken); + } + catch (WebSocketException exception) + { + _pending.TryRemove(id, out _); + throw new PrinterException(PrinterFailure.Unreachable, $"Moonraker's websocket closed while sending {method}.", exception); + } + finally + { + _sending.Release(); + } + + return (id, answer.Task); + } + + private async Task ReceiveAsync(CancellationToken cancellationToken) + { + var buffer = new byte[16 * 1024]; + using var message = new MemoryStream(); + try + { + while (_socket.State == WebSocketState.Open) + { + var received = await _socket.ReceiveAsync(buffer, cancellationToken); + if (received.MessageType == WebSocketMessageType.Close) + { + break; + } + + message.Write(buffer, 0, received.Count); + if (message.Length > MaxMessageBytes) + { + break; + } + + if (!received.EndOfMessage) + { + continue; + } + + Dispatch(message.ToArray()); + message.SetLength(0); + } + } + catch (Exception exception) when (exception is WebSocketException or OperationCanceledException) + { + } + finally + { + foreach (var pending in _pending.Values) + { + pending.TrySetException(new PrinterException(PrinterFailure.Unreachable, "Moonraker's websocket closed.")); + } + } + } + + private void Dispatch(byte[] frame) + { + JsonElement root; + try + { + using var document = JsonDocument.Parse(frame); + root = document.RootElement.Clone(); + } + catch (JsonException) + { + return; + } + + if (root.Str("method") is { } method) + { + _onNotification(method, root.TryGetProperty("params", out var parameters) ? parameters : default); + return; + } + + if (!root.TryGetProperty("id", out var idElement) || !idElement.TryGetInt32(out var id) || !_pending.TryGetValue(id, out var pending)) + { + return; + } + + if (root.Obj("error") is { } error) + { + pending.TrySetException(Failure(error)); + } + else + { + pending.TrySetResult(root.TryGetProperty("result", out var result) ? result : default); + } + } + + // Moonraker's error codes follow HTTP's; 503 is Klipper not being ready. + private static PrinterException Failure(JsonElement error) + { + var code = error.Num("code") is { } number ? (int)number : 0; + var failure = code switch + { + 401 => PrinterFailure.Unauthorized, + 403 => PrinterFailure.Forbidden, + 404 => PrinterFailure.NotFound, + 400 => PrinterFailure.BadRequest, + 503 => PrinterFailure.Conflict, + _ => PrinterFailure.ServerError, + }; + return new PrinterException(failure, $"Moonraker refused the request ({code}): {error.Str("message")}"); + } + + public async ValueTask DisposeAsync() + { + try + { + if (_socket.State == WebSocketState.Open) + { + using var closing = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, closing.Token); + } + } + catch (Exception exception) when (exception is WebSocketException or OperationCanceledException) + { + } + + await _stop.CancelAsync(); + await Completion.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + _socket.Dispose(); + _stop.Dispose(); + _sending.Dispose(); + } +} diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerSetup.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerSetup.cs new file mode 100644 index 0000000..c919160 --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerSetup.cs @@ -0,0 +1,76 @@ +using System.Net; +using MacroDeck.Localization; +using MacroDeck.Sdk.Actions; +using MacroDeck.Sdk.ConfigFlow; +using PrinterControl.ConfigFlow; +using PrinterControl.Core; + +namespace PrinterControl.Backends.Moonraker; + +internal sealed class MoonrakerSetup(MoonrakerHttp http, bool editing) : IPrinterSetup +{ + public const string ApiKeyStepId = "api-key"; + + public async Task StartAsync(CancellationToken cancellationToken) + { + try + { + var info = await http.GetAsync("server/info", null, cancellationToken); + return info.Status == HttpStatusCode.OK + ? PrinterSetupResult.Done(await HostnameAsync(null, cancellationToken)) + : PrinterSetupResult.Step(ApiKeyStep()); + } + catch (PrinterException) + { + return PrinterSetupResult.Error(ApiKeyStep(), Strings.Setup.Errors.Unreachable()); + } + } + + public async Task SubmitAsync(string stepId, IReadOnlyDictionary input, CancellationToken cancellationToken) + { + var apiKey = input.GetValueOrDefault(ConfigKeys.ApiKey)?.ToString()?.Trim(); + if (string.IsNullOrEmpty(apiKey)) + { + // Editing: an empty secret keeps the stored key. + return editing + ? PrinterSetupResult.Done() + : PrinterSetupResult.Error(ApiKeyStep(), fieldErrors: new Dictionary + { + [ConfigKeys.ApiKey] = MacroDeckStrings.Validation.Required(Strings.Setup.Moonraker.ApiKey.Label()), + }); + } + + try + { + var info = await http.GetAsync("server/info", apiKey, cancellationToken); + return info.Status == HttpStatusCode.OK + ? PrinterSetupResult.Done(await HostnameAsync(apiKey, cancellationToken)) + : PrinterSetupResult.Error(ApiKeyStep(), fieldErrors: new Dictionary + { + [ConfigKeys.ApiKey] = Strings.Setup.Moonraker.Errors.Unauthorized(), + }); + } + catch (PrinterException) + { + return PrinterSetupResult.Error(ApiKeyStep(), Strings.Setup.Errors.Unreachable()); + } + } + + private async Task HostnameAsync(string? apiKey, CancellationToken cancellationToken) => + (await http.GetAsync("printer/info", apiKey, cancellationToken)).Result?.Str("hostname"); + + private ConfigFlowStep ApiKeyStep() => new() + { + StepId = ApiKeyStepId, + Title = Strings.Setup.Moonraker.ApiKey.Title(), + Description = Strings.Setup.Moonraker.ApiKey.Description(), + Fields = + [ + ActionParameter.Secret( + ConfigKeys.ApiKey, + label: Strings.Setup.Moonraker.ApiKey.Label(), + description: Strings.Setup.Moonraker.ApiKey.Help(), + required: !editing), + ], + }; +} diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerStatus.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerStatus.cs new file mode 100644 index 0000000..d42f72e --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerStatus.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using PrinterControl.Core; + +namespace PrinterControl.Backends.Moonraker; + +// Notifications carry only the fields that changed, so every field is kept until it changes again. +internal sealed class MoonrakerStatus +{ + private readonly Dictionary> _objects = new(StringComparer.Ordinal); + + public void Clear() => _objects.Clear(); + + public void Merge(JsonElement status) + { + if (status.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var printerObject in status.EnumerateObject()) + { + if (printerObject.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!_objects.TryGetValue(printerObject.Name, out var fields)) + { + _objects[printerObject.Name] = fields = new Dictionary(StringComparer.Ordinal); + } + + foreach (var field in printerObject.Value.EnumerateObject()) + { + fields[field.Name] = field.Value.Clone(); + } + } + } + + public string? PrintState => Text("print_stats", "state"); + + public string? FileName => Text("print_stats", "filename") is { Length: > 0 } name ? name : null; + + // Klippy's state comes from server.info and its notifications; the webhooks object is unreliable for it. + public PrinterSnapshot ToSnapshot(string klippyState, string? klippyMessage) + { + var printState = PrintState; + var status = klippyState switch + { + "ready" => printState switch + { + "printing" => PrinterStatus.Printing, + "paused" => PrinterStatus.Paused, + _ => PrinterStatus.Operational, + }, + "startup" => PrinterStatus.Connecting, + "shutdown" or "error" => PrinterStatus.Error, + _ => PrinterStatus.Disconnected, + }; + + var active = status is PrinterStatus.Printing or PrinterStatus.Paused; + var progress = Number("virtual_sdcard", "progress"); + var printTime = Number("print_stats", "print_duration"); + var fileName = FileName; + + return new PrinterSnapshot + { + Online = true, + Status = status, + StateText = klippyState == "ready" ? printState ?? klippyState : klippyState, + Error = klippyState is "shutdown" or "error" ? NonEmpty(klippyMessage) + : printState == "error" ? NonEmpty(Text("print_stats", "message")) + : null, + FileName = fileName is null ? null : Path.GetFileName(fileName), + FileId = fileName, + Completion = active && progress is { } done ? Math.Clamp(done * 100, 0, 100) : null, + PrintTime = active ? Seconds(printTime) : null, + PrintTimeLeft = active && progress is > 0 and var p && printTime is { } elapsed ? Seconds((elapsed / p) - elapsed) : null, + CurrentZ = Field("gcode_move", "gcode_position") is { ValueKind: JsonValueKind.Array } position + && position.GetArrayLength() > 2 && position[2].TryGetDouble(out var z) ? z : null, + Temperatures = Temperatures(), + }; + } + + private Dictionary Temperatures() + { + var temperatures = new Dictionary(StringComparer.Ordinal); + foreach (var (name, fields) in _objects) + { + if (MoonrakerHeaters.ToHeaterId(name) is not { } heater) + { + continue; + } + + var actual = fields.TryGetValue("temperature", out var t) && t.TryGetDouble(out var a) ? a : (double?)null; + var target = fields.TryGetValue("target", out var g) && g.TryGetDouble(out var b) ? b : (double?)null; + + // A temperature_sensor chamber only reads; a heater_generic chamber wins when both exist. + if (heater == HeaterIds.Chamber && target is null && temperatures.ContainsKey(heater)) + { + continue; + } + + temperatures[heater] = new Temperature(actual, target); + } + + return temperatures; + } + + private JsonElement? Field(string printerObject, string field) => + _objects.TryGetValue(printerObject, out var fields) && fields.TryGetValue(field, out var value) ? value : null; + + private string? Text(string printerObject, string field) => + Field(printerObject, field) is { ValueKind: JsonValueKind.String } value ? value.GetString() : null; + + private double? Number(string printerObject, string field) => + Field(printerObject, field) is { ValueKind: JsonValueKind.Number } value ? value.GetDouble() : null; + + private static string? NonEmpty(string? text) => string.IsNullOrWhiteSpace(text) ? null : text; + + private static int? Seconds(double? value) => value is { } seconds && seconds >= 0 ? (int)Math.Round(seconds) : null; +} diff --git a/src/PrinterControl/Backends/Moonraker/MoonrakerUrls.cs b/src/PrinterControl/Backends/Moonraker/MoonrakerUrls.cs new file mode 100644 index 0000000..fda2545 --- /dev/null +++ b/src/PrinterControl/Backends/Moonraker/MoonrakerUrls.cs @@ -0,0 +1,12 @@ +using PrinterControl.Core; + +namespace PrinterControl.Backends.Moonraker; + +internal static class MoonrakerUrls +{ + public static Uri Websocket(Uri baseUri) => + new UriBuilder(new Uri(PrinterUrls.Normalize(baseUri), "websocket")) + { + Scheme = baseUri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", + }.Uri; +} diff --git a/src/PrinterControl/Core/HeaterIds.cs b/src/PrinterControl/Core/HeaterIds.cs index b85596a..c6aaf9e 100644 --- a/src/PrinterControl/Core/HeaterIds.cs +++ b/src/PrinterControl/Core/HeaterIds.cs @@ -1,7 +1,7 @@ namespace PrinterControl.Core; // Heater names in snapshots and ITemperatureControl, stored by actions. A backend maps its own (OctoPrint's -// "tool0", for example) onto these; a second extruder is "extruder1". +// "tool0", Klipper's "heater_bed") onto these; a second extruder is "extruder1". internal static class HeaterIds { public const string Extruder = "extruder"; diff --git a/src/PrinterControl/Core/PrinterModels.cs b/src/PrinterControl/Core/PrinterModels.cs index 4e28df4..6613694 100644 --- a/src/PrinterControl/Core/PrinterModels.cs +++ b/src/PrinterControl/Core/PrinterModels.cs @@ -5,7 +5,7 @@ namespace PrinterControl.Core; internal sealed record TemperatureProfile(string Name, double? Extruder, double? Bed, double? Chamber); -// Rotation in clockwise degrees. +// Rotation in clockwise degrees, as Moonraker reports it. internal sealed record WebcamSettings( bool Enabled, string? StreamUrl, diff --git a/src/PrinterControl/Localization/Strings.resx b/src/PrinterControl/Localization/Strings.resx index 3d9d5db..5263a73 100644 --- a/src/PrinterControl/Localization/Strings.resx +++ b/src/PrinterControl/Localization/Strings.resx @@ -52,6 +52,36 @@ Macro Deck needs access to this OctoPrint to control the printer. + + Sign in to Moonraker + + + This Moonraker only accepts trusted clients or an API key. + + + API key + + + Show it on the printer's host with curl http://localhost:7125/access/api_key. Or add this computer to trusted_clients in moonraker.conf, then no key is needed. + + + Moonraker refused the API key. + + + Firmware restart + + + Restart Klipper + + + Restart Moonraker + + + Reboot the host + + + Shut down the host + Sign in with diff --git a/src/PrinterControl/manifest.json b/src/PrinterControl/manifest.json index 23b2070..ff7f86a 100644 --- a/src/PrinterControl/manifest.json +++ b/src/PrinterControl/manifest.json @@ -4,7 +4,7 @@ "id": "com.pyflat.printer-control", "name": "Printer Control", "version": "1.0.0", - "description": "Watch and control your 3D printers through OctoPrint: live print progress, temperatures and events, pause, resume, cancel, preheat, move, G-code and lights, with a print status widget, the webcam and temperature variables for the history graph.", + "description": "Watch and control your 3D printers through OctoPrint or Moonraker (Klipper with Mainsail or Fluidd): live print progress, temperatures and events, pause, resume, cancel, preheat, move, G-code and lights, with a print status widget, the webcam and temperature variables for the history graph.", "icon": "Assets/icon.svg", "entrypoints": { "win-x64": { diff --git a/tests/PrinterControl.Tests/Backends/BackendContract.cs b/tests/PrinterControl.Tests/Backends/BackendContract.cs index c5ebcbe..674509e 100644 --- a/tests/PrinterControl.Tests/Backends/BackendContract.cs +++ b/tests/PrinterControl.Tests/Backends/BackendContract.cs @@ -1,5 +1,6 @@ using NUnit.Framework; using PrinterControl.Backends; +using PrinterControl.Backends.Moonraker; using PrinterControl.Backends.OctoPrint; using PrinterControl.Core; using PrinterControl.Tests.Backends.Example; @@ -14,6 +15,7 @@ internal static class AllFakeServers public static IReadOnlyList<(string BackendId, Func> Start)> All { get; } = [ (OctoPrintBackend.BackendId, async () => await FakeOctoPrint.StartAsync()), + (MoonrakerBackend.BackendId, async () => await FakeMoonraker.StartAsync()), (ExampleBackend.BackendId, async () => await ExampleServer.StartAsync()), ]; } diff --git a/tests/PrinterControl.Tests/Backends/MoonrakerTests.cs b/tests/PrinterControl.Tests/Backends/MoonrakerTests.cs new file mode 100644 index 0000000..f32f6c4 --- /dev/null +++ b/tests/PrinterControl.Tests/Backends/MoonrakerTests.cs @@ -0,0 +1,229 @@ +using MacroDeck.Localization; +using MacroDeck.Sdk.ConfigFlow; +using NUnit.Framework; +using PrinterControl.Backends; +using PrinterControl.Backends.Moonraker; +using PrinterControl.Backends.OctoPrint; +using PrinterControl.ConfigFlow; +using PrinterControl.Core; +using PrinterControl.Tests.Support; + +namespace PrinterControl.Tests.Backends; + +[TestFixture] +public sealed class MoonrakerContractTests : BackendContract +{ + private protected override IPrinterBackend CreateBackend(IHttpClientFactory http) => new MoonrakerBackend(http); + + private protected override async Task StartServerAsync() => await FakeMoonraker.StartAsync(); +} + +[TestFixture] +public sealed class MoonrakerTests +{ + private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10); + + private FakeMoonraker _server = null!; + private MoonrakerConnection? _printer; + + [SetUp] + public async Task StartServer() => _server = await FakeMoonraker.StartAsync(); + + [TearDown] + public async Task StopServer() + { + if (_printer is { } printer) + { + _printer = null; + await printer.DisposeAsync(); + } + + await _server.DisposeAsync(); + } + + private static PrinterConfigFlow Flow() => new( + new PrinterBackends([ + new OctoPrintBackend(new TestHttpClientFactory(), Serilog.Core.Logger.None), + new MoonrakerBackend(new TestHttpClientFactory()), + ]), + Serilog.Core.Logger.None); + + private Task SubmitAddressAsync(PrinterConfigFlow flow) => + flow.SubmitAsync( + PrinterConfigFlow.ConnectionStepId, + new Dictionary { [ConfigKeys.Url] = _server.BaseUri.ToString() }, + new FakeFlowContext(), + CancellationToken.None); + + private async Task OnlineAsync() + { + _printer = new MoonrakerConnection( + new PrinterConfig(Guid.NewGuid(), MoonrakerBackend.BackendId, "Voron", _server.BaseUri, _server.ApiKey, null), + Serilog.Core.Logger.None); + _printer.Start(); + var deadline = DateTime.UtcNow + Patience; + while (!_printer.Snapshot.Online || _printer.Settings.InstanceName is null) + { + Assert.That(DateTime.UtcNow, Is.LessThan(deadline), "never came online"); + await Task.Delay(20); + } + + return _printer; + } + + [Test] + public async Task A_trusted_client_is_set_up_without_a_key() + { + _server.ApiKey = null; + await using var flow = Flow(); + + var result = await SubmitAddressAsync(flow); + + using (Assert.EnterMultipleScope()) + { + Assert.That(result.Kind, Is.EqualTo(ConfigFlowResultKind.Complete)); + Assert.That(result.EntryTitle, Is.EqualTo("voron"), "the host name Moonraker reports"); + Assert.That(result.Values?[ConfigKeys.Backend].Value, Is.EqualTo(MoonrakerBackend.BackendId)); + } + } + + [Test] + public async Task Without_trust_setup_asks_for_the_api_key_and_checks_it() + { + await using var flow = Flow(); + + var ask = await SubmitAddressAsync(flow); + var wrong = await flow.SubmitAsync( + MoonrakerSetup.ApiKeyStepId, + new Dictionary { [ConfigKeys.ApiKey] = "wrong" }, + new FakeFlowContext(), + CancellationToken.None); + var right = await flow.SubmitAsync( + MoonrakerSetup.ApiKeyStepId, + new Dictionary { [ConfigKeys.ApiKey] = FakeMoonraker.Key }, + new FakeFlowContext(), + CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(ask.NextStep?.StepId, Is.EqualTo(MoonrakerSetup.ApiKeyStepId), "recognized as Moonraker although it refused the anonymous request"); + Assert.That(wrong.FieldErrors?.Keys, Does.Contain(ConfigKeys.ApiKey)); + Assert.That(right.Kind, Is.EqualTo(ConfigFlowResultKind.Complete)); + } + } + + [Test] + public async Task The_job_reports_progress_file_and_position() + { + await _server.ReportAsync(PrinterStatus.Printing); + var printer = await OnlineAsync(); + + var snapshot = printer.Snapshot; + using (Assert.EnterMultipleScope()) + { + Assert.That(snapshot.Status, Is.EqualTo(PrinterStatus.Printing)); + Assert.That(snapshot.FileName, Is.EqualTo("benchy.gcode")); + Assert.That(snapshot.FileId, Is.EqualTo("benchy.gcode")); + Assert.That(snapshot.Completion, Is.EqualTo(50)); + Assert.That(snapshot.PrintTime, Is.EqualTo(120)); + Assert.That(snapshot.PrintTimeLeft, Is.EqualTo(120), "from the progress, without slicer metadata"); + Assert.That(snapshot.CurrentZ, Is.EqualTo(0.2)); + } + } + + [Test] + public async Task The_webcam_and_power_devices_come_from_moonraker() + { + var printer = await OnlineAsync(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(printer.Settings.Webcam.StreamUrl, Is.EqualTo("/webcam/?action=stream")); + Assert.That(printer.Settings.Webcam.Rotation, Is.EqualTo(90)); + Assert.That(printer.Settings.Outputs.Select(o => o.Id), Is.EqualTo((string[])["power-chamber-light"])); + } + } + + [Test] + public async Task A_webcam_changed_in_moonraker_is_read_again() + { + var printer = await OnlineAsync(); + + _server.WebcamRotation = 180; + await _server.ChangeWebcamsAsync(); + + var deadline = DateTime.UtcNow + Patience; + while (printer.Settings.Webcam.Rotation != 180) + { + Assert.That(DateTime.UtcNow, Is.LessThan(deadline), "the webcam was never read again"); + await Task.Delay(20); + } + } + + [Test] + public async Task Switching_a_power_device_addresses_it_by_name() + { + var printer = await OnlineAsync(); + + await printer.SetOutputAsync("power-chamber-light", true, CancellationToken.None); + var states = await printer.ReadOutputsAsync(CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(_server.PowerDevices["Chamber Light"], Is.EqualTo("on")); + Assert.That(states["power-chamber-light"], Is.True); + } + } + + [Test] + public async Task Temperatures_are_set_with_klippers_heater_names() + { + var printer = await OnlineAsync(); + + await printer.SetTemperatureAsync(HeaterIds.Bed, 60, CancellationToken.None); + await printer.SetTemperatureAsync(HeaterIds.Extruder, 215.5, CancellationToken.None); + + Assert.That(_server.Calls, Is.SupersetOf((string[])[ + "printer.gcode.script SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=60", + "printer.gcode.script SET_HEATER_TEMPERATURE HEATER=extruder TARGET=215.5", + ])); + } + + [Test] + public async Task A_chamber_without_a_heater_cannot_be_set() + { + var printer = await OnlineAsync(); + + var failure = Assert.ThrowsAsync(() => printer.SetTemperatureAsync(HeaterIds.Chamber, 40, CancellationToken.None)); + + Assert.That(failure?.Failure, Is.EqualTo(PrinterFailure.NotSupported)); + } + + [Test] + public async Task System_commands_have_names_and_reach_moonraker() + { + var printer = await OnlineAsync(); + + var commands = await printer.GetSystemCommandsAsync(CancellationToken.None); + await printer.RunSystemCommandAsync("klipper/firmware-restart", CancellationToken.None); + + using (Assert.EnterMultipleScope()) + { + Assert.That(commands.Select(c => c.Name), Has.None.EqualTo(default(LocalizedText))); + Assert.That(_server.Calls, Does.Contain("printer.firmware_restart")); + } + } + + [Test] + public async Task A_resumed_print_is_not_reported_as_started_again() + { + await _server.ReportAsync(PrinterStatus.Paused); + var printer = await OnlineAsync(); + var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + printer.EventReceived += (_, e) => received.TrySetResult(e.Event); + + await _server.RaiseAsync(PrinterEventKind.PrintStarted); + + Assert.That((await received.Task.WaitAsync(Patience)).Kind, Is.EqualTo(PrinterEventKind.PrintResumed)); + } +} diff --git a/tests/PrinterControl.Tests/Support/FakeMoonraker.cs b/tests/PrinterControl.Tests/Support/FakeMoonraker.cs new file mode 100644 index 0000000..85bb450 --- /dev/null +++ b/tests/PrinterControl.Tests/Support/FakeMoonraker.cs @@ -0,0 +1,294 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Channels; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using PrinterControl.Core; + +namespace PrinterControl.Tests.Support; + +// Moonraker's HTTP info endpoints and JSON-RPC websocket, plus /api/version the way its +// octoprint_compat imitates OctoPrint. +internal sealed class FakeMoonraker : IFakePrinterServer +{ + public const string Key = "moonraker-api-key"; + + private readonly WebApplication _app; + private readonly ConcurrentQueue _calls = new(); + private readonly ConcurrentDictionary> _sockets = new(); + private readonly Lock _gate = new(); + private int _nextSocket; + private string _klippy = "ready"; + private string _printState = "standby"; + + private FakeMoonraker(WebApplication app, Uri baseUri) + { + _app = app; + BaseUri = baseUri; + } + + public Uri BaseUri { get; } + + // Null: this computer is a trusted client and needs no key. + public string? ApiKey { get; set; } = Key; + + public int CommandsReceived => Calls.Count(c => !c.StartsWith("server.", StringComparison.Ordinal) + && !c.StartsWith("printer.objects.", StringComparison.Ordinal) + && c is not ("printer.info" or "machine.device_power.devices")); + + // Every JSON-RPC method called, with G-code scripts as "printer.gcode.script