diff --git a/KlipperMon.xcodeproj/project.pbxproj b/KlipperMon.xcodeproj/project.pbxproj index 10732d2..b85601c 100644 --- a/KlipperMon.xcodeproj/project.pbxproj +++ b/KlipperMon.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ E124B9D929941A4D00C0D2D2 /* PrinterConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */; }; + E16378B229A43CE1002F05E9 /* KlipperMonScratchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16378B129A43CE1002F05E9 /* KlipperMonScratchTests.swift */; }; E180B5E92992CD9100425DB0 /* KlipperMonApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B5E82992CD9100425DB0 /* KlipperMonApp.swift */; }; E180B5ED2992CD9200425DB0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E180B5EC2992CD9200425DB0 /* Assets.xcassets */; }; E180B5F02992CD9200425DB0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E180B5EF2992CD9200425DB0 /* Preview Assets.xcassets */; }; @@ -20,6 +21,7 @@ E180B61F2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */; }; E180B6222993256E00425DB0 /* PrinterRequestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B6212993256E00425DB0 /* PrinterRequestManager.swift */; }; E1E8B07729949E2700BABE4B /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = E1E8B07629949E2700BABE4B /* Starscream */; }; + E1E8B07929955ABE00BABE4B /* PrinterStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E8B07829955ABE00BABE4B /* PrinterStats.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,6 +44,7 @@ /* Begin PBXFileReference section */ E124B9D72993FE5500C0D2D2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterConfigView.swift; sourceTree = ""; }; + E16378B129A43CE1002F05E9 /* KlipperMonScratchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlipperMonScratchTests.swift; sourceTree = ""; }; E180B5E52992CD9100425DB0 /* KlipperMon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KlipperMon.app; sourceTree = BUILT_PRODUCTS_DIR; }; E180B5E82992CD9100425DB0 /* KlipperMonApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlipperMonApp.swift; sourceTree = ""; }; E180B5EC2992CD9200425DB0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -57,6 +60,7 @@ E180B61C2992D53700425DB0 /* PrinterObjectsQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterObjectsQuery.swift; sourceTree = ""; }; E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlipperMonMenuBarExtraView.swift; sourceTree = ""; }; E180B6212993256E00425DB0 /* PrinterRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterRequestManager.swift; sourceTree = ""; }; + E1E8B07829955ABE00BABE4B /* PrinterStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterStats.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -119,6 +123,7 @@ E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */, E180B6212993256E00425DB0 /* PrinterRequestManager.swift */, E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */, + E1E8B07829955ABE00BABE4B /* PrinterStats.swift */, ); path = KlipperMon; sourceTree = ""; @@ -135,6 +140,7 @@ isa = PBXGroup; children = ( E180B5FF2992CD9300425DB0 /* KlipperMonTests.swift */, + E16378B129A43CE1002F05E9 /* KlipperMonScratchTests.swift */, ); path = KlipperMonTests; sourceTree = ""; @@ -284,6 +290,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E1E8B07929955ABE00BABE4B /* PrinterStats.swift in Sources */, E180B61D2992D53700425DB0 /* PrinterObjectsQuery.swift in Sources */, E180B5F52992CD9200425DB0 /* KlipperMon.xcdatamodeld in Sources */, E124B9D929941A4D00C0D2D2 /* PrinterConfigView.swift in Sources */, @@ -299,6 +306,7 @@ buildActionMask = 2147483647; files = ( E180B6002992CD9300425DB0 /* KlipperMonTests.swift in Sources */, + E16378B229A43CE1002F05E9 /* KlipperMonScratchTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/KlipperMon/Info.plist b/KlipperMon/Info.plist index 2f09cb0..ad569a5 100644 --- a/KlipperMon/Info.plist +++ b/KlipperMon/Info.plist @@ -2,6 +2,8 @@ + LSUIElement + NSBonjourServices _moonraker._tcp. diff --git a/KlipperMon/KlipperMonApp.swift b/KlipperMon/KlipperMonApp.swift index 8392428..4bc4945 100644 --- a/KlipperMon/KlipperMonApp.swift +++ b/KlipperMon/KlipperMonApp.swift @@ -14,16 +14,16 @@ struct KlipperMonMenuBarApp: App { @State var currentIcon = "move.3d" var body: some Scene { - WindowGroup(id: "floating-stats") { - KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon) - .environment(\.managedObjectContext, persistenceController.container.viewContext) - //.frame(width: 300, height: 140) - } - //.windowResizability(.contentSize) +// WindowGroup(id: "floating-stats") { +// KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon) +// .environment(\.managedObjectContext, persistenceController.container.viewContext) +// } - Window("Configuration", id: "soyuz_cfg", content: { + WindowGroup("Configuration", id: "soyuz_cfg", content: { PrinterConfigView() + //.frame(minWidth: 300, maxWidth: 600, minHeight: 60, maxHeight: 100) }) + .windowResizability(.contentSize) MenuBarExtra("Soyuz", systemImage: currentIcon) { KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon) diff --git a/KlipperMon/KlipperMonMenuBarExtraView.swift b/KlipperMon/KlipperMonMenuBarExtraView.swift index 7c9cd0e..b042009 100644 --- a/KlipperMon/KlipperMonMenuBarExtraView.swift +++ b/KlipperMon/KlipperMonMenuBarExtraView.swift @@ -7,129 +7,94 @@ import SwiftUI import AppKit +import Network struct KlipperMonMenuBarExtraView: View { + // The threshhold considered a burn-risk, at which point certain UI elements turn red. let DANGERTEMP = 40.0 @Environment(\.openWindow) var openWindow @ObservedObject var printerManager = PrinterRequestManager.shared - @State var printerObjectsQuery: PrinterObjectsQuery? @State var printPercentage: Double = 0 - // TODO: Don't forget, create @State variable for printer status (i.e. "Printing", etc) - // and programmatically add a "connecting" section - @State var printerStatus: String = "" - @Binding var currentMenuBarIcon: String @State var hotendHotTemp: Bool = false @State var bedHotTemp: Bool = false - //@State var nwBrowserDiscoveredItems: [NWEndpoint] = [] - - //var nwBrowser = NWBrowser(for: .bonjour(type: "_moonraker._tcp.", domain: "local."), using: .tcp) - - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() - // TODO: Use @published API data instead of instance state variable var body: some View { VStack { // Printer Readouts - if let queryResults = printerManager.printerObjectsQuery { - Text(queryResults.result.status.print_stats.state.capitalized) - .font(.title) - .padding(4) - // Print information - HStack { - Image(systemName: "pencil.tip") - .rotationEffect(Angle(degrees: 180)) - .offset(x: 5.5, y: 4) - .font(.system(size: 24)) - ProgressView(value: queryResults.result.status.virtual_sdcard.progress, total: 1.0) - .progressViewStyle(.linear) - .offset(x: 10) - Text("\(Int(queryResults.result.status.virtual_sdcard.progress * 100))%") - .padding(2) - .padding([.leading], 8) - } - // Temperatures - HStack { - // Hot-end temperature + //if let printerStats = printerManager.printerStats { + if(printerManager.isConnected) { + VStack { + Text(printerManager.state.capitalized) + .font(.title) + .padding(4) + // Print information HStack { - Image(systemName: "flame") - .foregroundColor( hotendHotTemp ? .red : .white ) - .opacity( hotendHotTemp ? 1.0 : 0.3 ) - Text("Hotend") - .font(.headline) - Spacer() - Text("\(Int(queryResults.result.status.extruder.temperature))°C") + Image(systemName: "pencil.tip") + .rotationEffect(Angle(degrees: 180)) + .offset(x: 5.5, y: 4) + .font(.system(size: 24)) + ProgressView(value: printerManager.progress, total: 1.0) + .progressViewStyle(.linear) + .offset(x: 10) + Text("\(Int(printerManager.progress * 100))%") + .padding(2) + .padding([.leading], 8) } - // Bed temperature + // Temperatures HStack { - Image(systemName: "flame") - .foregroundColor( bedHotTemp ? .red : .white ) - .opacity( bedHotTemp ? 1.0 : 0.3 ) - Text("Plate") - .font(.headline) - Spacer() - Text("\(Int(queryResults.result.status.heater_bed.temperature))°C") + // Hot-end temperature + HStack { + Image(systemName: "flame") + .foregroundColor( printerManager.extruderTemperature > DANGERTEMP ? .red : .white ) + .opacity( printerManager.extruderTemperature > DANGERTEMP ? 1.0 : 0.3 ) + Text("Hotend") + .font(.headline) + Spacer() + Text("\(Int(printerManager.extruderTemperature))°C") + } + // Bed temperature + HStack { + Image(systemName: "flame") + .foregroundColor( printerManager.bedTemperature > DANGERTEMP ? .red : .white ) + .opacity( printerManager.bedTemperature > DANGERTEMP ? 1.0 : 0.3 ) + Text("Plate") + .font(.headline) + Spacer() + Text("\(Int(printerManager.bedTemperature))°C") + } } + Divider() } - Divider() } } - .frame(minWidth: 220, minHeight: 100) - // .overlay { - // if !printerManager.printerCommsOkay { - // RoundedRectangle(cornerRadius: 10, style: .circular) - // .foregroundColor(.black) - // .frame(minWidth: 300, minHeight: 100) - // .opacity(0.6) - // } - // } + //.frame(minWidth: 220, minHeight: 100) // Footer information HStack { Button { print("Button pressed") openWindow(id: "soyuz_cfg") } label: { - Text("Server Config") + Text("Printers") .foregroundColor(.white) } Spacer() - if(printerManager.printerCommsOkay) { + if(printerManager.isConnected) { Image(systemName: "network") Text("Online") } else { - Image(systemName: "xmark") + Image(systemName: "exclamationmark.triangle") Text("Offline") } } - .padding(4) - .frame(minWidth: 220, maxWidth: 250) - .onReceive(timer) { input in - Task { - //await printerManager.queryPrinterStats() - - if let query = printerManager.printerObjectsQuery { - hotendHotTemp = (query.result.status.extruder.temperature > DANGERTEMP) ? true : false - bedHotTemp = (query.result.status.heater_bed.temperature > DANGERTEMP) ? true : false - printerStatus = query.result.status.print_stats.state.capitalized - } else { - printerStatus = "Connecting..." - } - } - } - // Testing bonjour stuff - ForEach(printerManager.nwBrowserDiscoveredItems, id: \.hashValue) { endpoint in - Button { - printerManager.resolveBonjourHost(endpoint) - } label: { - Text(endpoint.debugDescription) - } - } + .padding(2) + .frame(minWidth: 220, maxWidth: 375) } } diff --git a/KlipperMon/PrinterConfigView.swift b/KlipperMon/PrinterConfigView.swift index b80d8bc..a374f0b 100644 --- a/KlipperMon/PrinterConfigView.swift +++ b/KlipperMon/PrinterConfigView.swift @@ -6,10 +6,48 @@ // import SwiftUI +import Network struct PrinterConfigView: View { + @ObservedObject var printerManager = PrinterRequestManager.shared + var body: some View { - Text("Config Printer In Here") + VStack { + if(printerManager.isConnected) { + HStack { + Image(systemName: "network") + Text(printerManager.connection.endpoint.toFriendlyString()) + Text("\(printerManager.socketHost):\(printerManager.socketPort)") + Button { + printerManager.socket?.disconnect() + } label: { + Text("Disconnect") + } + } + .frame(width: 500, height: 80) + } else { + VStack { + Text("Auto-detected Printers") + .font(.title) + ForEach(printerManager.nwBrowserDiscoveredItems, id: \.hashValue) { result in + HStack { + Text(result.endpoint.toFriendlyString()) + Button { + printerManager.resolveBonjourHost(result.endpoint) + } label: { + Text("Connect") + .foregroundColor(.white) + .padding() + } + } + } + } + .frame(width: 500, height: 100) + } + } + .onAppear { + NSApplication.shared.activate(ignoringOtherApps: true) + } } } @@ -18,3 +56,12 @@ struct PrinterConfigView_Previews: PreviewProvider { PrinterConfigView() } } + +extension NWEndpoint { + func toFriendlyString() -> String { + let regex = /\.(.+)/ + let match = self.debugDescription.firstMatch(of: regex) + return self.debugDescription.replacingOccurrences(of: match!.0, with: "") + } +} + diff --git a/KlipperMon/PrinterObjectsQuery.swift b/KlipperMon/PrinterObjectsQuery.swift index 970b143..2298476 100644 --- a/KlipperMon/PrinterObjectsQuery.swift +++ b/KlipperMon/PrinterObjectsQuery.swift @@ -7,6 +7,7 @@ import Foundation +// Root struct to decode for REST response struct PrinterObjectsQuery: Decodable { let result: ResultsData } @@ -16,34 +17,76 @@ struct ResultsData: Decodable { let status: StatusData } +// Individual update replies for JSON-RPC +struct jsonRpcUpdate: Decodable { + let method: String? + let params: jsonRpcParams +} + +struct jsonRpcParams: Decodable { + let status: StatusData? + let timestamp: Double? + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + self.status = try container.decode(StatusData.self) + self.timestamp = try container.decode(Double.self) + } +} +// +// public init(from decoder: Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// +// do { +// self = .statusData(try container.decode(StatusData.self, forKey: .statusData)) +// } catch DecodingError.keyNotFound { +// print("Error") +// self = .double(try container.decode(Double.self)) +// //self = .timestamp(try container.decode(Double.self, forKey: .timestamp)) +// } +// +// } + +// Root structs to decode for JSON-RPC response +struct jsonRpcResponse: Decodable { + let result: jsonRpcResult + +} + +struct jsonRpcResult: Decodable { + let eventtime: Double + let status: StatusData +} + +// Shared data sub-structs struct StatusData: Decodable { - let virtual_sdcard: VirtualSDCardData - let extruder: ExtruderData - let print_stats: PrintStatsData - let heater_bed: HeaterBedData + let virtual_sdcard: VirtualSDCardData? + let extruder: ExtruderData? + let print_stats: PrintStatsData? + let heater_bed: HeaterBedData? } struct VirtualSDCardData: Decodable { let file_path: String? - let progress: Double - let is_active: Bool + let progress: Double? + let is_active: Bool? } struct ExtruderData: Decodable { - let temperature: Double - let target: Double - let power: Double + let temperature: Double? + let target: Double? + let power: Double? } struct PrintStatsData: Decodable { - let filename: String - let print_duration: Double - let filament_used: Double - let state: String + let filename: String? + let print_duration: Double? + let filament_used: Double? + let state: String? } struct HeaterBedData: Decodable { - let temperature: Double - let target: Double - let power: Double + let temperature: Double? + let target: Double? + let power: Double? } diff --git a/KlipperMon/PrinterRequestManager.swift b/KlipperMon/PrinterRequestManager.swift index ffb1744..876e63b 100644 --- a/KlipperMon/PrinterRequestManager.swift +++ b/KlipperMon/PrinterRequestManager.swift @@ -24,81 +24,117 @@ struct JsonRpcRequest: Codable { } } +//@MainActor class PrinterRequestManager: ObservableObject, WebSocketDelegate { - func didReceive(event: Starscream.WebSocketEvent, client: Starscream.WebSocket) { - switch event { - case .connected(let headers): - print("websocket is connected: \(headers)") - let jsonRpcRequest = JsonRpcRequest(method: "printer.objects.subscribe", - params: ["objects": - ["extruder": nil, - "virtual_sdcard": nil, - "heater_bed": nil, - "print_stats": nil] - ]) - - print(String(data: try! JSONEncoder().encode(jsonRpcRequest), encoding: .utf8)!) - socket.write(data: try! JSONEncoder().encode(jsonRpcRequest), completion: { - print("Data transferred.") - }) - case .disconnected(let reason, let code): - print("websocket is disconnected: \(reason) with code: \(code)") - case .text(let string): - print("Received text: \(string)") - case .binary(let data): - print("Received data: \(data.count)") - case .ping(_): - break - case .pong(_): - break - case .viabilityChanged(_): - break - case .reconnectSuggested(_): - break - case .cancelled: - break - case .error(let error): - print("[error] Starscream: \(error.debugDescription)") - } - } - - // REST query results - @Published var printerObjectsQuery: PrinterObjectsQuery? - - // Websocket RPC-JSON endpoints discovered via bonjour - @Published var nwBrowserDiscoveredItems: [NWEndpoint] = [] - - @Published var printerCommsOkay = false - - var socket: WebSocket! - - private var socketHost, socketPort: String? - - //var nwBrowser: NWBrowser! - let nwBrowser = NWBrowser(for: .bonjour(type: "_moonraker._tcp", domain: "local."), using: .tcp) - var connection: NWConnection! + let WEBSOCKET_TIMEOUT_INTERVAL: TimeInterval = 60.0 static let shared = PrinterRequestManager() + // Debug stuff + let startDate = Date() + let startDateString: String + let filename: URL + + func writeToDebugLog(_ output: String) { + do { + let fileHandle = try FileHandle(forWritingTo: filename) + defer { + fileHandle.closeFile() + } + fileHandle.seekToEndOfFile() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SS" + + let debugOutput = String("\(dateFormatter.string(from: Date())) - \(output)\n") + fileHandle.write(debugOutput.data(using: .utf8)!) + } catch { + print("[error] writeToDebugLog - \(error)") + } + } + + // Websocket JSON-RPC endpoints discovered via bonjour + @Published var nwBrowserDiscoveredItems: [NWBrowser.Result] = [] + + + // Websocket JSON-RPC published data + @Published var state: String + @Published var progress: Double + @Published var extruderTemperature: Double + @Published var bedTemperature: Double + + // Active connection published data + @Published var isConnected = false + @Published var socketHost: String + @Published var socketPort: String + + let nwBrowser = NWBrowser(for: .bonjourWithTXTRecord(type: "_moonraker._tcp", domain: "local."), using: .tcp) + var connection: NWConnection! + + var socket: WebSocket? + var lastPingDate = Date() + + // TODO: Set this up to actually reconnect + + // Parse a JSON-RPC query-response message + func parse_response(_ response: jsonRpcResponse) { + state = response.result.status.print_stats?.state ?? "" + progress = response.result.status.virtual_sdcard?.progress ?? 0.0 + extruderTemperature = response.result.status.extruder?.temperature ?? 0.0 + bedTemperature = response.result.status.heater_bed?.temperature ?? 0.0 + + print(response) + } + + // Parse a JSON-RPC update message + func parse_update(_ update: jsonRpcUpdate) { + if let newState = update.params.status?.print_stats?.state { + state = newState + } + if let newProgress = update.params.status?.virtual_sdcard?.progress { + progress = newProgress + } + if let newExtruderTemp = update.params.status?.extruder?.temperature { + extruderTemperature = newExtruderTemp + } + if let newBedTemp = update.params.status?.heater_bed?.temperature { + bedTemperature = newBedTemp + } + } + private init() { + state = "" + progress = 0.0 + extruderTemperature = 0.0 + bedTemperature = 0.0 + socketHost = "" + socketPort = "" + //reconnectionTimer = nil + + // MARK: Debug stuff + startDateString = "\(startDate)\n\n" + filename = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("klippermon-debug-\(startDateString).txt") + + do { + try startDateString.write(to: filename, atomically: true, encoding: .utf8) + } catch { + print("[error] Couldn't write to \(filename) - \(error)") + } + // MARK: Bonjour browser initialization at instantiation nwBrowser.browseResultsChangedHandler = { (newResults, changes) in print("[update] Results changed.") newResults.forEach { result in print(result) - self.nwBrowserDiscoveredItems.append(result.endpoint) + self.nwBrowserDiscoveredItems.append(result) } } - // State update handler + // Bonjour browser state update handler nwBrowser.stateUpdateHandler = { newState in switch newState { case .failed(let error): print("[error] nwbrowser: \(error)") case .ready: print("[ready] nwbrowser") - if let innerEndpoint = self.connection?.currentPath?.remoteEndpoint, case .hostPort(let host, let port) = innerEndpoint { - print("Connected to:", "\(host):\(port)") - } case .setup: print("[setup] nwbrowser") default: @@ -109,22 +145,32 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { nwBrowser.start(queue: DispatchQueue.main) } - // Called from the UI, providing an endpoint. + // Called from the UI with an endpoint. // Momentarily connect/disconnects from the endpoint to retrieve the host/port // calls private function openWebsocket to process the host/port func resolveBonjourHost(_ endpoint: NWEndpoint) { + // Debug stuff + endpoint.txtRecord?.forEach({ (key: String, value: NWTXTRecord.Entry) in + print("\(key): \(value)") + }) + connection = NWConnection(to: endpoint, using: .tcp) connection.stateUpdateHandler = { [self] state in switch state { case .ready: if let innerEndpoint = connection.currentPath?.remoteEndpoint, case .hostPort(let host, let port) = innerEndpoint { - print("Connected to \(host):\(port)") + let hostPortDebugOutput = "Connected to \(host):\(port)" + + print(hostPortDebugOutput) + writeToDebugLog(hostPortDebugOutput) + let hostString = "\(host)" - let regex = try! Regex("%en0") + let regex = try! Regex("%(.+)") let match = hostString.firstMatch(of: regex) let sanitizedHost = hostString.replacingOccurrences(of: match!.0, with: "") print("[sanitized] Resolved \(sanitizedHost):\(port)") + socketHost = sanitizedHost socketPort = "\(port)" connection.cancel() @@ -137,40 +183,91 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate { connection.start(queue: .global()) } + func reconnectWebsocket() { + if socket == nil { + return + } + + socket!.disconnect() + self.openWebsocket() + //socket!.write(ping: "PING!".data(using: .utf8)!) + } + // Opens the websocket connection // TODO: host and port should be function arguments probably maybe private func openWebsocket() { - if let host = socketHost, let port = socketPort { - //let fullUrlString = "http://\(socketHost):\(socketPort)/websocket" - var request = URLRequest(url: URL(string: "http://\(host):\(port)/websocket")!) - request.timeoutInterval = 5 - socket = WebSocket(request: request) - socket.delegate = self - socket.connect() + //let fullUrlString = "http://\(socketHost):\(socketPort)/websocket" + var request = URLRequest(url: URL(string: "http://\(socketHost):\(socketPort)/websocket")!) + request.timeoutInterval = 5 + socket = WebSocket(request: request) + socket!.delegate = self + socket!.connect() + + // TODO: Check that this keeps the connection alive properly + Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [self] timer in + //self.checkWebsocketIsAlive() } } - // Old REST way to do it - // TODO: Stop using this. - func queryPrinterStats() async { - guard let url = URL(string: "http://10.0.21.39/printer/objects/query?extruder&virtual_sdcard&print_stats&heater_bed") else { - fatalError("Missing URL") - } - - let urlRequest = URLRequest(url: url) - do { - let (data, response) = try await URLSession.shared.data(for: urlRequest) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - print("Error with response.") - return + // MARK: delegate callback for Starscream WebSocketClient + func didReceive(event: Starscream.WebSocketEvent, client: Starscream.WebSocket) { + switch event { + case .connected(let headers): + isConnected = true + print("websocket is connected: \(headers)") + writeToDebugLog("Connected to WebSocket") + let jsonRpcRequest = JsonRpcRequest(method: "printer.objects.subscribe", + params: ["objects": + ["extruder": nil, + "virtual_sdcard": nil, + "heater_bed": nil, + "print_stats": nil] + ]) + + print(String(data: try! JSONEncoder().encode(jsonRpcRequest), encoding: .utf8)!) + socket?.write(data: try! JSONEncoder().encode(jsonRpcRequest), completion: { + print("[send] json-rpc printer.objects.subscribe query") + }) + case .disconnected(let reason, let code): + isConnected = false + print("websocket is disconnected: \(reason) with code: \(code)") + writeToDebugLog("Websocket is disconnected: \(reason) with code: \(code)") + case .text(let string): + self.writeToDebugLog(string) + // Check for initial RPC response + let statusResponse = try? JSONDecoder().decode(jsonRpcResponse.self, from: Data(string.utf8)) + if let statusResponseSafe = statusResponse { + self.parse_response(statusResponseSafe) } - // handle data as JSON - let decoder = JSONDecoder() - printerObjectsQuery = try decoder.decode(PrinterObjectsQuery.self, from: data) - printerCommsOkay = true - } catch { - print("Exception thrown: \(error)") - printerCommsOkay = false + // Check for RPC updates + if let updateResponse = try? JSONDecoder().decode(jsonRpcUpdate.self, from: Data(string.utf8)) { + self.parse_update(updateResponse) + } + case .binary(let data): + self.writeToDebugLog(String(data: data, encoding: .utf8)!) + print("Received data: \(data.count)") + case .ping(_): + print("PING! \(Date())") + // TODO: There's probably a better way to do this + if(lastPingDate.addingTimeInterval(WEBSOCKET_TIMEOUT_INTERVAL) < Date.now) { + print("Forcing reconnection of websocket..") + self.reconnectWebsocket() + } + lastPingDate = Date() + break + case .pong(_): + print("PONG!") + break + case .viabilityChanged(_): + break + case .reconnectSuggested(_): + break + case .cancelled: + isConnected = false + case .error(let error): + isConnected = false + print("[error] Starscream: \(error.debugDescription)") } } + } diff --git a/KlipperMon/PrinterStats.swift b/KlipperMon/PrinterStats.swift new file mode 100644 index 0000000..3318e3a --- /dev/null +++ b/KlipperMon/PrinterStats.swift @@ -0,0 +1,44 @@ +// +// PrinterStats.swift +// KlipperMon +// +// Created by maddiefuzz on 2/9/23. +// + +import Foundation + +class PrinterStats: ObservableObject { + @Published var state: String + @Published var progress: Double + @Published var extruderTemperature: Double + @Published var bedTemperature: Double + + init(response: jsonRpcResponse) { + state = response.result.status.print_stats?.state ?? "" + progress = response.result.status.virtual_sdcard?.progress ?? 0.0 + extruderTemperature = response.result.status.extruder?.temperature ?? 0.0 + bedTemperature = response.result.status.heater_bed?.temperature ?? 0.0 + + print(response) + } + + func update(update: jsonRpcUpdate) { +// print(update) + if let newState = update.params.status?.print_stats?.state { + //state = update.params[0].print_stats?.state + state = newState + } + if let newProgress = update.params.status?.virtual_sdcard?.progress { + print("Update progress") + progress = newProgress + } + if let newExtruderTemp = update.params.status?.extruder?.temperature { + print("Update extruder temp \(newExtruderTemp)") + extruderTemperature = newExtruderTemp + } + if let newBedTemp = update.params.status?.heater_bed?.temperature { + print("Update heated bed \(newBedTemp)") + bedTemperature = newBedTemp + } + } +} diff --git a/KlipperMonTests/KlipperMonScratchTests.swift b/KlipperMonTests/KlipperMonScratchTests.swift new file mode 100644 index 0000000..361a632 --- /dev/null +++ b/KlipperMonTests/KlipperMonScratchTests.swift @@ -0,0 +1,17 @@ +// +// KlipperMonScratchTests.swift +// KlipperMonTests +// +// Created by maddiefuzz on 2/20/23. +// + +import XCTest + +class ScratchTests: XCTestCase { + + func testOneAndOneIsEqual() { + let a = 1 + let b = 1 + XCTAssertEqual(a, b, "1 did not equal 1.") + } +}