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 and Moonraker. 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.
src/PrinterControl/
Program.cs builder chain + the named HttpClient and AddPrinterBackends
PrinterControlIntegration.cs lifecycle, config flow, the action list
PrinterControlIntegration.*.cs the same partial class per capability: Variables (eager, per printer),
Events (printer events and state changes), Widgets
(IWidgetTypeProvider + IUiProvider)
Core/ backend-neutral: PrinterConfig, PrinterSnapshot, PrinterModels (settings,
files, outputs, events), PrinterConnection (abstract: state, reconnect
loop), PrinterFeatures (the optional feature interfaces), PrinterRegistry,
PrinterException, PrinterUrls, JsonRead
Backends/
IPrinterBackend.cs the backend contract, PrinterBackends, AddPrinterBackends (registration)
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
Variables/ PrinterVariables: field list, ids, names, readings
Ui/ widget views and their shared parts (rings, gauges, path glyphs), options,
samples + [UiPreview]s, config view
VideoStreams/ webcam provider, its integration partial, the webcam widget view
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
EndToEndTests.cs the plugin in PluginTestHarness against the fake
VideoStreams/ the webcam provider and widget against the harness
SDK behaviour is documented upstream in the Macro Deck repository. OctoPrint's API is documented at https://docs.octoprint.org/en/master/api/. Look there instead of guessing.
Public API. Persisted in users' profiles, widgets and automations. Renaming one breaks them:
- the plugin id
com.pyflat.printer-controland the backend ids (IPrinterBackend.Id, stored per entry) - the printer key (
PrinterConfig.Key, the config entry id as 32 hex digits). Variable ids, widget data and stream ids store it, which is why a printer rename breaks nothing - variable ids
p<key>-<field>andp<key>-output-<output id>, namesprinter_<printer>_<field>andprinter_<printer>_output_<output>(PrinterVariables, a test pins them) - action ids and parameter names, event ids and payload names, the status ids in
PrinterStatusText.Id - button state ids of the state provider actions (
on/off,printing/paused/idle,connected/connecting/disconnected,unavailable); a button that adopted one stores it - widget type ids and the widget data keys (
WidgetTypes,WidgetOptions) - config keys (
ConfigKeys), the provider idwebcams - ids a backend hands out and actions store: file ids, system command ids, output ids. OctoPrint's are
local:<path>,<source>/<action>andgpio<pin>(GPIO Control's API addresses outputs by list position, which reordering changes, so the id follows the pin)
Backends. Everything outside Backends/ talks to PrinterConnection and the Core types only; no
backend type leaks into actions, variables, events or the UI.
- A connection pushes its state (
PublishSnapshot,PublishSettings,PublishEvent,PublishOutputStates); readers never wait on the network. Prefer the server's push channel to polling. RunSessionAsyncconnects and returns only when the connection dropped; the base class retries with backoff (2 s doubling to 30 s), logs once per kind of failure and resets to offline in between.PrinterConnectiononly runs the session and holds the state. Commands come from the feature interfaces inCore/PrinterFeatures.cs(IJobControl,IFileControl,ITemperatureControl,IGcodeControl,IMotionControl,IPrinterLink,ISystemCommands,IOutputControl); a connection implements the ones its server supports. Actions get one throughPrinterAction.Require<T>, which reports the action as unavailable on a printer without it.IMotionControldefaults to plain G-code (G28, relativeG1,M220,M221) on top ofIGcodeControl. Override a method only when the server has a better call for it.- Commands throw
PrinterException;PrinterFailure.NotSupportedfor a single command the server lacks within a feature it otherwise has. - The backend maps its own events onto
PrinterEventKindand drops duplicates; the integration only publishes. - Read server JSON through
JsonRead: fields are missing, null or retyped between versions and plugins.
OctoPrint.
OctoPrintConnectionlogs in passively with the API key, reads/api/settings, then authenticates the push socket (/sockjs/websocket) withuser:session. State comes from push frames. The one exception is GPIO Control, which pushes nothing: its states are read every 2 s while the socket is up and the settings list outputs (PollGpioAsync).- A
currentframe only carries the temperature samples since the last one, often none; keep the previous reading (PushMessages.MergeTemperatures). - OctoPrint 1.9 moved the webcam settings to
plugins.classicwebcam; older servers usewebcam. Both are read. - OctoPrint reports a cancel as
PrintCancelledand again asPrintFailedwith reasoncancelled; only the first becomesprint-cancelled(PushMessages.ReadEvent).
Moonraker. Written against the documented API (https://moonraker.readthedocs.io) and tested on real Klipper printers by a closed tester group.
- Everything after setup goes over the websocket (
MoonrakerRpc); the API key is in the handshake. Klippy's state comes fromserver.infoand thenotify_klippy_*notifications, not from thewebhooksobject, which Moonraker documents as unreliable for that. While Klippy is ready, the printer objects come from a subscription;notify_status_updateonly carries changed fields (MoonrakerStatus.Merge). printer.gcode.scriptanswers 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.statechanging (MoonrakerConnection.PrintEvent). - Heaters:
extruder,extruder1,heater_bed, and a chamber asheater_generic chamber(settable) ortemperature_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
printerdynamic choice first; empty means the only printer. ActionResultstays truthful. Job, connection and temperature actions wait (bounded, 5 s) for the pushed state to show the result (PrinterAction.ConfirmAsync) and answerAcceptedwhen it has not arrived. Switching an output reads the state back. Moves, extrusion, fan, rates and G-code are only queued by the server and cannot be confirmed, so they answerAccepted. Never turn either intoSuccess.- A 409 (
PrinterFailure.Conflict) means the printer is in the wrong state; it maps toProviderRejected.
Secrets. The API key lives only in the host's secret store (ConfigFlowValue.Secret). It never goes
into a log line, a variable, an event, a widget or a video stream description.
Video streams. IVideoStreamIntegration (SDK 3.0.0-beta.15) lives in VideoStreams/. It is still
behind EnableVideoStreams (on by default) and #if VIDEO_STREAMS in WidgetTypes and
PrinterControlIntegration.Widgets; remove both in a follow-up.
- The webcam is described as
VideoStreamSessionDescription.Mjpeg, pointing at the server's stream. Macro Deck fetches that URL itself and relays the media to its clients, so it only has to be reachable from this machine and must carry no credential.PrinterConnection.WebcamStreamresolves a relative stream against the server and swaps a loopback host for the server's host (PrinterUrls.ResolveStream). - The webcam widget builds its
UiVideoStreamonce per session. Only the overlay is re-templated, so a progress frame never makes the client reopen the stream (a test checks the patches).
Start from tests/PrinterControl.Tests/Backends/Example/, the smallest complete backend. The backend
contract keeps it working, so it is always a correct starting point.
- Copy
ExampleBackend.cstosrc/PrinterControl/Backends/<Name>/and give the backend a new, permanentId. Replace the made-up protocol with the server's API: the session loop (prefer the server's push channel to polling), the state mapping and the feature interfaces its server supports. Start with none and add them one by one;IMotionControlcomes free withIGcodeControl. DetectAsync: recognize the server from an endpoint it answers without credentials, and rule out servers that imitate it (Moonraker answers OctoPrint's/api/version). Setup tries every backend in registration order, so a detection must not claim another backend's server.CreateSetup: the steps after the shared address step. A server without a login returnsPrinterSetupResult.DonefromStartAsync; one with a login asks for it and returnsDonewith the server's own name and any granted key (ConfigKeys.ApiKey, as a secret). Name, address, webcam and the backend id are handled byPrinterConfigFlow.- Map the server's states onto
PrinterStatusand its events ontoPrinterEventKind; heaters are named the ids inHeaterIds(extruder,bed,chamber), mapped from the server's own names at the edge. - Register it in
AddPrinterBackends. Setup strings go underSetup.<Name>.*inStrings.resx. - Tests: copy
ExampleServer.cstoSupport/as the fake of the real API (it implementsIFakePrinterServer), add it toAllFakeServers, and derive a<Name>ContractTestsfromBackendContractlikeExampleContractTests. The backend is done when the contract passes; add tests for what is special about it next to it. - README: the supported table, setup steps, and every network call it makes in Privacy.
- Ship it only once it has run against a real server, tested by someone who keeps access to one and can reproduce its bug reports. The Creator Guidelines (Quality and Functionality) refuse features the publisher cannot test; until then the backend stays on its own branch.
- Identity lives in
manifest.jsononly. Ids in source are local ids (^[a-z][a-z0-9]*(-[a-z0-9]+)*$). - Keep
Program.csas the plain builder chain. Constructors must be side-effect free:Build()constructs everything to validate it. Never set a listener URL. Only write toMACRO_DECK_PLUGIN_DATA_DIRECTORY. InitializeAsyncruns again after every reconnect and config change, so it must be idempotent (PrinterRegistry.ApplyAsynckeeps unchanged connections). Never callCatalogChangedfrom it.- Variables are eager, one set per printer grouped by
VariableConfiguration, like the built-in OBS and Twitch integrations. An on-demand catalog would hide them from the normal pickers (the history graph's included).DeclaredVariablesshows templates until a printer exists, andAnnounceVariablesAfterInitializationsendsCatalogChangedonce per printer set, afterInitializeAsyncreturns, because the host's first describe can run before the printers are read. Never send it from insideInitializeAsync. Temperatures are whole degrees for the history graph. - Forward
context.CancellationToken. No blocking waits and noasync voidin SDK contract types. - Every user-facing string is a dotted key in
Localization/Strings.resx; checkMacroDeckStringsfirst. A text that depends on a value is one key with a placeholder, never a concatenation. Log and exception messages stay English literals. The server's own texts (state text, file and profile names) pass through. - Every
UiElement.Keymust match^[A-Za-z0-9][A-Za-z0-9._-]*$; build every view through a realUiViewin a test. A widget data schema key the Set Border action writes (border) must stay allowed. - Log through Serilog. Structured properties are not persisted to the log file, so put what matters into the message template.
- Build warning-free in both configurations. Do not relax
Directory.Build.props. - C# is tab-indented.
- Prefer no comment. Write one only for what the code cannot say: a race, a workaround, a protocol quirk, why a shape was chosen. Never restate what a well-named type, method or property already says; if a name needs a comment to be understood, rename it.
- A comment is one or two plain English
//lines directly above the code it explains. No XML doc summaries, no banners or dividers. A link to the server's API docs is a fine comment on a backend. - No em dashes in code, comments, commits or docs.
make build && make test
make conformance # after changing capability shape, cancellation or the manifest
make preview STORE=1 # store images of every [UiPreview] in artifacts/previews/make preview renders each [UiPreview] scenario (Ui/WidgetSamples.cs) at 1x1, 2x1 and 2x2 deck
cells; STORE=1 pads them onto the 16:9 canvas the store crops card artwork to (tools/StoreCanvas.cs).
Text comes from the plugin's catalog only with a macrodeck-plugin newer than 3.0.0-beta.15; older ones
draw it as [[plugin:...]] placeholders.
The tests run the plugin against fake servers, so they need no printer. To see a real one, make run
with Macro Deck running and a printer server on the network.
Before a release, fetch the Creator Guidelines, the blocked packages and the SDK policy again (URLs in the Device Battery Info AGENTS.md) and check the change against them. The README's Privacy section must list every network call and every file the plugin stores.
- The display name is "Printer Control" and the README says the plugin is unofficial and not endorsed by the projects it talks to: a name that is only a third-party product name would read as that product's own plugin (guideline 5).
ai.generatedAssetsistruebecauseAssets/icon.svgwas drawn with AI (guideline 9). Set it back tofalseonly together with replacing the icon by one that was not.
- Work on a branch (
feature/,fix/,refactor/,chore/,docs/,ci/), keep changes focused. - Do not push or open a pull request unless asked. No AI attribution or co-author trailers.
- A release is a pushed
vX.Y.Ztag matchingmanifest.json'sversion(make release). - Update README.md when the build, run, packaging or user-facing behaviour changes.