functions/public/Enter-KlippyConsole.ps1
|
function Enter-KlippyConsole { <# .SYNOPSIS Enters an interactive G-code console for a Klipper printer. .DESCRIPTION Opens an interactive REPL-style console for sending G-code commands to a Klipper printer. Features include: - Tab completion for G-code commands and macros - Command history navigation with Up/Down arrows - Line editing with Left/Right arrows - Real-time command responses via WebSocket - Built-in commands for status, temperatures, etc. - Colored output (errors in red) .PARAMETER Id The unique identifier of the printer. .PARAMETER PrinterName The friendly name of the printer. .PARAMETER InputObject A printer object from pipeline input. .EXAMPLE Enter-KlippyConsole Opens console for the default printer. .EXAMPLE Enter-KlippyConsole -PrinterName "voronv2" Opens console for the specified printer. .EXAMPLE Get-KlippyPrinter -PrinterName "voronv2" | Enter-KlippyConsole Opens console via pipeline. .NOTES Built-in commands: :exit, :quit, :q - Exit the console :help, :h - Show help :status, :s - Show printer status :temps, :t - Show temperatures :pos - Show toolhead position :macros - List available macros :clear - Clear the screen .OUTPUTS None. Interactive console session. #> [CmdletBinding(DefaultParameterSetName = 'Default')] param( [Parameter(Mandatory = $true, ParameterSetName = 'ById')] [ValidateNotNullOrEmpty()] [string]$Id, [Parameter(Mandatory = $true, ParameterSetName = 'ByName', Position = 0)] [ValidateNotNullOrEmpty()] [string]$PrinterName, [Parameter(Mandatory = $true, ParameterSetName = 'ByObject', ValueFromPipeline = $true)] [PSCustomObject]$InputObject, [Parameter()] [switch]$ShowMessages ) begin { # Check for PSReadLine module if (-not (Get-Module -Name PSReadLine)) { try { Import-Module PSReadLine -ErrorAction Stop } catch { Write-Error "PSReadLine module is required for the interactive console. Please install it with: Install-Module PSReadLine" return } } } process { # Resolve printer $resolveParams = @{} switch ($PSCmdlet.ParameterSetName) { 'ById' { $resolveParams['Id'] = $Id } 'ByName' { $resolveParams['PrinterName'] = $PrinterName } 'ByObject' { $resolveParams['InputObject'] = $InputObject } } $printer = Resolve-KlippyPrinterTarget @resolveParams # Store original PSReadLine settings to restore later $originalTabHandler = $null $originalPrompt = $function:prompt try { # Test connection first Write-Host "Connecting to " -NoNewline Write-Host $printer.PrinterName -ForegroundColor Cyan -NoNewline Write-Host "..." $status = Get-KlippyStatus -Id $printer.Id -ErrorAction Stop Write-Host "Connected! " -ForegroundColor Green -NoNewline Write-Host "Printer state: " -NoNewline $stateColor = switch ($status.State) { 'ready' { 'Green' } 'printing' { 'Yellow' } 'paused' { 'Yellow' } 'error' { 'Red' } default { 'White' } } Write-Host $status.State -ForegroundColor $stateColor # Fetch G-code commands for tab completion Write-Host "Loading G-code commands..." -ForegroundColor Gray $gcodeCommands = Get-KlippyGcodeCommands -Printer $printer Write-Host "Loaded $($gcodeCommands.Count) commands." -ForegroundColor Gray # Connect WebSocket for real-time responses Write-Host "Establishing WebSocket connection..." -ForegroundColor Gray $ws = $null try { $ws = New-KlippyWebSocketClient -Printer $printer if ($ShowMessages) { Write-Host "[DEBUG] WebSocket connected to $($ws.Uri)" -ForegroundColor DarkGray } # Identify the connection to Moonraker (required to receive notifications) $null = $ws.SendRpc("server.connection.identify", @{ client_name = "KlippyCLI" version = "0.1.0" type = "other" url = "https://github.com/darkoperator/KlippyCLI" }) # Wait for identify response $identifyResponse = $ws.Receive(5000) if ($ShowMessages) { Write-Host "[DEBUG] Identify response: $($identifyResponse | ConvertTo-Json -Compress)" -ForegroundColor DarkGray } # Subscribe to printer objects (like Mainsail does) $null = $ws.SendRpc("printer.objects.subscribe", @{ objects = @{ gcode_move = $null toolhead = $null print_stats = $null } }) # Drain any pending messages from subscription $drainCount = 0 do { $pending = $ws.Receive(500) if ($pending) { $drainCount++ if ($ShowMessages) { $method = if ($pending.method) { $pending.method } else { "rpc-response" } Write-Host "[DEBUG] Drained: $method" -ForegroundColor DarkGray } } } while ($pending -and $drainCount -lt 10) Write-Host "WebSocket connected." -ForegroundColor Gray if ($ShowMessages) { Write-Host "[DEBUG] Drained $drainCount initial messages" -ForegroundColor DarkGray } } catch { Write-Host "WebSocket connection failed: $_" -ForegroundColor Yellow Write-Host "Using HTTP fallback." -ForegroundColor Yellow $ws = $null } # Display welcome banner Write-Host "" Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan Write-Host "║ KlippyCLI Interactive Console ║" -ForegroundColor Cyan Write-Host "║ Printer: " -ForegroundColor Cyan -NoNewline Write-Host ("{0,-51}" -f $printer.PrinterName) -ForegroundColor White -NoNewline Write-Host "║" -ForegroundColor Cyan Write-Host "║ Type G-code commands or :help for built-in commands ║" -ForegroundColor Cyan Write-Host "║ Tab = autocomplete, Escape = skip wait, :exit = quit ║" -ForegroundColor Cyan Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan Write-Host "" # Console prompt - store in module scope for tab completion access $consolePrompt = "[$($printer.PrinterName)]> " # Setup tab completion - store commands in module scope for access from key handler $script:KlippyConsoleCompletions = @{ Commands = @($gcodeCommands | ForEach-Object { $_.Name }) Builtins = @(':exit', ':quit', ':q', ':help', ':h', ':status', ':s', ':temps', ':t', ':pos', ':macros', ':clear') Prompt = $consolePrompt } Set-PSReadLineKeyHandler -Key Tab -ScriptBlock { param($key, $arg) $line = $null $cursor = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) # Handle empty line if ([string]::IsNullOrEmpty($line) -or $cursor -eq 0) { return } $word = $line.Substring(0, $cursor).Split(' ')[-1].ToUpper() # Get matching completions from module-scoped variable $completions = $script:KlippyConsoleCompletions if ($null -eq $completions) { return } $allCommands = @($completions.Commands) + @($completions.Builtins) $matches = @($allCommands | Where-Object { $_ -and $_.ToUpper().StartsWith($word) } | Sort-Object) if ($matches.Count -eq 0) { return } elseif ($matches.Count -eq 1) { # Single match - complete it $wordStart = $cursor - $word.Length [Microsoft.PowerShell.PSConsoleReadLine]::Replace($wordStart, $word.Length, $matches[0] + " ") } else { # Multiple matches - find longest common prefix and show list $commonPrefix = $matches[0] foreach ($m in $matches) { while ($commonPrefix.Length -gt 0 -and -not $m.ToUpper().StartsWith($commonPrefix.ToUpper())) { $commonPrefix = $commonPrefix.Substring(0, $commonPrefix.Length - 1) } } # If we can extend the current word, do it if ($commonPrefix.Length -gt $word.Length) { $wordStart = $cursor - $word.Length [Microsoft.PowerShell.PSConsoleReadLine]::Replace($wordStart, $word.Length, $commonPrefix) } else { # Show matches below the current line Write-Host "" $columns = 4 $maxLen = ($matches | Measure-Object -Property Length -Maximum).Maximum + 2 $i = 0 foreach ($m in $matches) { Write-Host ("{0,-$maxLen}" -f $m) -NoNewline -ForegroundColor Cyan $i++ if ($i % $columns -eq 0) { Write-Host "" } } if ($i % $columns -ne 0) { Write-Host "" } # Redraw our custom prompt and current input Write-Host $completions.Prompt -NoNewline -ForegroundColor Yellow Write-Host $line -NoNewline } } } # Main REPL loop $exitConsole = $false while (-not $exitConsole) { try { # Display prompt and get input Write-Host $consolePrompt -NoNewline -ForegroundColor Yellow $inputLine = [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $null) # Skip empty lines if ([string]::IsNullOrWhiteSpace($inputLine)) { continue } $inputLine = $inputLine.Trim() # Check for built-in commands if ($inputLine.StartsWith(':')) { $builtinResult = Invoke-KlippyConsoleBuiltin -Command $inputLine -Printer $printer -GcodeCommands $gcodeCommands if ($builtinResult.Exit) { $exitConsole = $true continue } if ($builtinResult.Output) { Write-Host $builtinResult.Output } continue } # Execute G-code command try { $useHttp = $true # Check WebSocket state and attempt reconnection if needed if ($ShowMessages) { $wsState = if ($ws) { $ws.WebSocket.State } else { "null" } $wsConnected = if ($ws) { $ws.IsConnected } else { "null" } Write-Host "[DEBUG] WS check: ws=$($null -ne $ws) IsConnected=$wsConnected State=$wsState" -ForegroundColor DarkGray } # Attempt to reconnect if WebSocket was disconnected or nulled (idle timeout, error, etc.) $needsReconnect = ($null -eq $ws) -or (-not $ws.IsConnected) -or ($ws.WebSocket.State -ne [System.Net.WebSockets.WebSocketState]::Open) if ($needsReconnect) { if ($ShowMessages) { Write-Host "[DEBUG] WebSocket needs reconnect (ws=$($null -ne $ws))" -ForegroundColor DarkGray } # Clean up old connection if exists if ($ws) { try { $ws.WebSocket.Dispose() } catch { } $ws = $null } try { $ws = New-KlippyWebSocketClient -Printer $printer $null = $ws.SendRpc("server.connection.identify", @{ client_name = "KlippyCLI" version = "0.1.0" type = "other" url = "https://github.com/darkoperator/KlippyCLI" }) $null = $ws.Receive(5000) # Wait for identify response # Re-subscribe to printer objects for status updates $null = $ws.SendRpc("printer.objects.subscribe", @{ objects = @{ gcode_move = $null toolhead = $null print_stats = $null } }) # Drain subscription response and any pending messages $drainCount = 0 do { $pending = $ws.Receive(200) if ($pending) { $drainCount++ } } while ($pending -and $drainCount -lt 5) if ($ShowMessages) { Write-Host "[DEBUG] WebSocket reconnected successfully (drained $drainCount msgs)" -ForegroundColor DarkGray } } catch { if ($ShowMessages) { Write-Host "[DEBUG] WebSocket reconnect failed: $_" -ForegroundColor DarkGray } $ws = $null } } # Debug: show state after potential reconnection if ($ShowMessages) { $wsState2 = if ($ws) { $ws.WebSocket.State } else { "null" } $wsConnected2 = if ($ws) { $ws.IsConnected } else { "null" } Write-Host "[DEBUG] After reconnect check: ws=$($null -ne $ws) IsConnected=$wsConnected2 State=$wsState2" -ForegroundColor DarkGray } # Try WebSocket if available and open if ($ws -and $ws.IsConnected -and $ws.WebSocket.State -eq [System.Net.WebSockets.WebSocketState]::Open) { if ($ShowMessages) { Write-Host "[DEBUG] Sending via WebSocket: $inputLine" -ForegroundColor DarkGray } try { # Fire-and-forget pattern (like Mainsail) - send command without waiting for RPC response $null = $ws.SendRpc("printer.gcode.script", @{ script = $inputLine }) # Async message handling - display messages as they arrive # User can press Escape to return to prompt early (like Mainsail's always-available input) $commandComplete = $false $overallTimeout = [datetime]::UtcNow.AddMinutes(10) while (-not $commandComplete -and [datetime]::UtcNow -lt $overallTimeout) { # Check for user interrupt (Escape key) - allows user to "take control" like Mainsail if ([Console]::KeyAvailable) { $key = [Console]::ReadKey($true) if ($key.Key -eq [ConsoleKey]::Escape) { Write-Host "" Write-Host "// Returned to prompt (messages may still arrive)" -ForegroundColor DarkGray $commandComplete = $true continue } } # Non-blocking receive with short timeout $message = $ws.Receive(500) if ($null -ne $message) { # Debug: show message method if ($ShowMessages) { $method = if ($message.method) { $message.method } elseif ($message.result) { "rpc-result" } elseif ($message.error) { "rpc-error" } else { "unknown" } # Only show non-status updates in detail to reduce noise if ($method -eq 'notify_gcode_response') { Write-Host "[DEBUG] GCODE_RESPONSE: $($message.params[0])" -ForegroundColor Magenta } elseif ($method -notin @('notify_status_update', 'notify_proc_stat_update')) { $msgJson = $message | ConvertTo-Json -Compress -Depth 3 if ($msgJson.Length -gt 200) { $msgJson = $msgJson.Substring(0, 200) + "..." } Write-Host "[DEBUG] $method : $msgJson" -ForegroundColor DarkGray } } if ($message.error) { # RPC error (e.g., invalid command syntax) Write-Host "!! $($message.error.message)" -ForegroundColor Red $commandComplete = $true } elseif ($message.result) { # RPC result - command completed successfully Write-Host "ok" -ForegroundColor Green $commandComplete = $true } elseif ($message.method -eq 'notify_gcode_response') { # G-code response - display immediately (async notification) $responseText = $message.params[0] if ($responseText -match '^!!') { Write-Host $responseText -ForegroundColor Red $commandComplete = $true } elseif ($responseText -match '^//') { Write-Host $responseText -ForegroundColor Yellow } elseif ($responseText -eq 'ok') { # Some commands send 'ok' via notification too - ignore if we'll get RPC result } else { Write-Host $responseText -ForegroundColor Cyan } } # Silently ignore other notifications (status updates, etc.) } } if (-not $commandComplete) { Write-Host "// Command timed out (10 min)" -ForegroundColor Yellow } $useHttp = $false } catch { $wsError = $_.Exception.Message if ([string]::IsNullOrEmpty($wsError)) { $wsError = $_.Exception.GetType().Name } Write-Host "// WebSocket error: $wsError" -ForegroundColor Yellow if ($ShowMessages) { Write-Host "[DEBUG] Full exception: $_" -ForegroundColor DarkGray } Write-Host "// Falling back to HTTP for this command" -ForegroundColor Yellow # Mark as disconnected - reconnection logic will handle next command if ($ws) { $ws.IsConnected = $false } } } # Fallback to HTTP (blocking, but necessary when WebSocket unavailable) if ($useHttp) { if ($ShowMessages) { Write-Host "[DEBUG] Using HTTP fallback for: $inputLine" -ForegroundColor DarkGray } $encodedScript = [System.Uri]::EscapeDataString($inputLine) $endpoint = "printer/gcode/script?script=$encodedScript" try { $null = Invoke-KlippyJsonRpc -Printer $printer -Method $endpoint -Timeout 600 Write-Host "ok" -ForegroundColor Green } catch { $httpError = $_.Exception.Message if ($httpError -match 'Klipper.*?:\s*(.+)$') { $httpError = $Matches[1] } Write-Host "!! $httpError" -ForegroundColor Red } } } catch { $errorMsg = $_.Exception.Message if ($errorMsg -match 'Klipper.*?:\s*(.+)$') { $errorMsg = $Matches[1] } Write-Host "!! $errorMsg" -ForegroundColor Red } } catch [System.Management.Automation.PipelineStoppedException] { # Ctrl+C pressed Write-Host "" Write-Host "// Use :exit or :q to quit the console" -ForegroundColor Yellow } catch { Write-Host "!! Error: $_" -ForegroundColor Red } } Write-Host "" Write-Host "Goodbye!" -ForegroundColor Cyan } catch { Write-Error "Console error: $_" } finally { # Cleanup if ($ws) { try { $ws.Close() Write-Verbose "WebSocket closed" } catch { Write-Verbose "WebSocket close error: $_" } } # Reset PSReadLine Tab handler try { Set-PSReadLineKeyHandler -Key Tab -Function TabCompleteNext } catch { # Ignore } # Clean up module-scoped completion data $script:KlippyConsoleCompletions = $null } } } function Invoke-KlippyConsoleBuiltin { <# .SYNOPSIS Handles built-in console commands. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Command, [Parameter(Mandatory)] [PSCustomObject]$Printer, [Parameter()] [array]$GcodeCommands ) $result = @{ Exit = $false Output = $null } $cmd = $Command.ToLower().Trim() switch -Regex ($cmd) { '^:(exit|quit|q)$' { $result.Exit = $true } '^:(help|h)$' { $helpText = @" KlippyCLI Console Commands ══════════════════════════ :exit, :quit, :q Exit the console :help, :h Show this help :status, :s Show printer status :temps, :t Show temperatures :pos Show toolhead position :macros List available macros :clear Clear the screen G-code commands are sent directly to Klipper. Press Tab for auto-completion. Press Escape during a command to return to prompt early. "@ $result.Output = $helpText } '^:(status|s)$' { try { $status = Get-KlippyStatus -Id $Printer.Id $output = [System.Text.StringBuilder]::new() $output.AppendLine("") | Out-Null $output.AppendLine(" Printer Status") | Out-Null $output.AppendLine(" ══════════════") | Out-Null $output.AppendLine(" State: $($status.State)") | Out-Null $output.AppendLine(" Filename: $($status.Filename ?? 'None')") | Out-Null $output.AppendLine(" Progress: $($status.Progress)%") | Out-Null $output.AppendLine("") | Out-Null $result.Output = $output.ToString() } catch { $result.Output = "!! Failed to get status: $_" } } '^:(temps|t)$' { try { $temps = Get-KlippyObject -Id $Printer.Id -ObjectName "heater_bed", "extruder" $output = [System.Text.StringBuilder]::new() $output.AppendLine("") | Out-Null $output.AppendLine(" Temperatures") | Out-Null $output.AppendLine(" ════════════") | Out-Null if ($temps.Extruder) { $output.AppendLine(" Extruder: $([Math]::Round($temps.Extruder.Temperature, 1))°C / $($temps.Extruder.Target)°C") | Out-Null } if ($temps.HeaterBed) { $output.AppendLine(" Bed: $([Math]::Round($temps.HeaterBed.Temperature, 1))°C / $($temps.HeaterBed.Target)°C") | Out-Null } $output.AppendLine("") | Out-Null $result.Output = $output.ToString() } catch { $result.Output = "!! Failed to get temperatures: $_" } } '^:pos$' { try { $toolhead = Get-KlippyObject -Id $Printer.Id -ObjectName "toolhead" $pos = $toolhead.Position $output = [System.Text.StringBuilder]::new() $output.AppendLine("") | Out-Null $output.AppendLine(" Toolhead Position") | Out-Null $output.AppendLine(" ═════════════════") | Out-Null if ($pos) { $output.AppendLine(" X: $([Math]::Round($pos[0], 3)) mm") | Out-Null $output.AppendLine(" Y: $([Math]::Round($pos[1], 3)) mm") | Out-Null $output.AppendLine(" Z: $([Math]::Round($pos[2], 3)) mm") | Out-Null $output.AppendLine(" E: $([Math]::Round($pos[3], 3)) mm") | Out-Null } $output.AppendLine(" Homed: $($toolhead.HomedAxes ?? 'unknown')") | Out-Null $output.AppendLine("") | Out-Null $result.Output = $output.ToString() } catch { $result.Output = "!! Failed to get position: $_" } } '^:macros$' { $macros = $GcodeCommands | Where-Object { $_.CommandType -eq 'Macro' } | Sort-Object Name $output = [System.Text.StringBuilder]::new() $output.AppendLine("") | Out-Null $output.AppendLine(" Available Macros ($($macros.Count))") | Out-Null $output.AppendLine(" ═══════════════════════════") | Out-Null foreach ($macro in $macros) { $output.AppendLine(" $($macro.Name)") | Out-Null } $output.AppendLine("") | Out-Null $result.Output = $output.ToString() } '^:clear$' { Clear-Host } default { $result.Output = "!! Unknown command: $Command. Type :help for available commands." } } return $result } |