Project opens websocket and reconnects if ping replies from the printer stop

This commit is contained in:
Madeline 2023-02-21 00:35:54 -05:00
parent ab849a721b
commit d084ebb1ac
9 changed files with 416 additions and 193 deletions

View File

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
E124B9D929941A4D00C0D2D2 /* PrinterConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */; }; 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 */; }; E180B5E92992CD9100425DB0 /* KlipperMonApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B5E82992CD9100425DB0 /* KlipperMonApp.swift */; };
E180B5ED2992CD9200425DB0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E180B5EC2992CD9200425DB0 /* Assets.xcassets */; }; E180B5ED2992CD9200425DB0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E180B5EC2992CD9200425DB0 /* Assets.xcassets */; };
E180B5F02992CD9200425DB0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E180B5EF2992CD9200425DB0 /* Preview 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 */; }; E180B61F2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */; };
E180B6222993256E00425DB0 /* PrinterRequestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B6212993256E00425DB0 /* PrinterRequestManager.swift */; }; E180B6222993256E00425DB0 /* PrinterRequestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E180B6212993256E00425DB0 /* PrinterRequestManager.swift */; };
E1E8B07729949E2700BABE4B /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = E1E8B07629949E2700BABE4B /* Starscream */; }; E1E8B07729949E2700BABE4B /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = E1E8B07629949E2700BABE4B /* Starscream */; };
E1E8B07929955ABE00BABE4B /* PrinterStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E8B07829955ABE00BABE4B /* PrinterStats.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -42,6 +44,7 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
E124B9D72993FE5500C0D2D2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; E124B9D72993FE5500C0D2D2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterConfigView.swift; sourceTree = "<group>"; }; E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterConfigView.swift; sourceTree = "<group>"; };
E16378B129A43CE1002F05E9 /* KlipperMonScratchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlipperMonScratchTests.swift; sourceTree = "<group>"; };
E180B5E52992CD9100425DB0 /* KlipperMon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KlipperMon.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; E180B5E82992CD9100425DB0 /* KlipperMonApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlipperMonApp.swift; sourceTree = "<group>"; };
E180B5EC2992CD9200425DB0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; E180B5EC2992CD9200425DB0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@ -57,6 +60,7 @@
E180B61C2992D53700425DB0 /* PrinterObjectsQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterObjectsQuery.swift; sourceTree = "<group>"; }; E180B61C2992D53700425DB0 /* PrinterObjectsQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterObjectsQuery.swift; sourceTree = "<group>"; };
E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlipperMonMenuBarExtraView.swift; sourceTree = "<group>"; }; E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KlipperMonMenuBarExtraView.swift; sourceTree = "<group>"; };
E180B6212993256E00425DB0 /* PrinterRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterRequestManager.swift; sourceTree = "<group>"; }; E180B6212993256E00425DB0 /* PrinterRequestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterRequestManager.swift; sourceTree = "<group>"; };
E1E8B07829955ABE00BABE4B /* PrinterStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrinterStats.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -119,6 +123,7 @@
E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */, E180B61E2992DBB000425DB0 /* KlipperMonMenuBarExtraView.swift */,
E180B6212993256E00425DB0 /* PrinterRequestManager.swift */, E180B6212993256E00425DB0 /* PrinterRequestManager.swift */,
E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */, E124B9D829941A4D00C0D2D2 /* PrinterConfigView.swift */,
E1E8B07829955ABE00BABE4B /* PrinterStats.swift */,
); );
path = KlipperMon; path = KlipperMon;
sourceTree = "<group>"; sourceTree = "<group>";
@ -135,6 +140,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
E180B5FF2992CD9300425DB0 /* KlipperMonTests.swift */, E180B5FF2992CD9300425DB0 /* KlipperMonTests.swift */,
E16378B129A43CE1002F05E9 /* KlipperMonScratchTests.swift */,
); );
path = KlipperMonTests; path = KlipperMonTests;
sourceTree = "<group>"; sourceTree = "<group>";
@ -284,6 +290,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E1E8B07929955ABE00BABE4B /* PrinterStats.swift in Sources */,
E180B61D2992D53700425DB0 /* PrinterObjectsQuery.swift in Sources */, E180B61D2992D53700425DB0 /* PrinterObjectsQuery.swift in Sources */,
E180B5F52992CD9200425DB0 /* KlipperMon.xcdatamodeld in Sources */, E180B5F52992CD9200425DB0 /* KlipperMon.xcdatamodeld in Sources */,
E124B9D929941A4D00C0D2D2 /* PrinterConfigView.swift in Sources */, E124B9D929941A4D00C0D2D2 /* PrinterConfigView.swift in Sources */,
@ -299,6 +306,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
E180B6002992CD9300425DB0 /* KlipperMonTests.swift in Sources */, E180B6002992CD9300425DB0 /* KlipperMonTests.swift in Sources */,
E16378B229A43CE1002F05E9 /* KlipperMonScratchTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>LSUIElement</key>
<false/>
<key>NSBonjourServices</key> <key>NSBonjourServices</key>
<array> <array>
<string>_moonraker._tcp.</string> <string>_moonraker._tcp.</string>

View File

@ -14,16 +14,16 @@ struct KlipperMonMenuBarApp: App {
@State var currentIcon = "move.3d" @State var currentIcon = "move.3d"
var body: some Scene { var body: some Scene {
WindowGroup(id: "floating-stats") { // WindowGroup(id: "floating-stats") {
KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon) // KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon)
.environment(\.managedObjectContext, persistenceController.container.viewContext) // .environment(\.managedObjectContext, persistenceController.container.viewContext)
//.frame(width: 300, height: 140) // }
}
//.windowResizability(.contentSize)
Window("Configuration", id: "soyuz_cfg", content: { WindowGroup("Configuration", id: "soyuz_cfg", content: {
PrinterConfigView() PrinterConfigView()
//.frame(minWidth: 300, maxWidth: 600, minHeight: 60, maxHeight: 100)
}) })
.windowResizability(.contentSize)
MenuBarExtra("Soyuz", systemImage: currentIcon) { MenuBarExtra("Soyuz", systemImage: currentIcon) {
KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon) KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon)

View File

@ -7,129 +7,94 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import Network
struct KlipperMonMenuBarExtraView: View { struct KlipperMonMenuBarExtraView: View {
// The threshhold considered a burn-risk, at which point certain UI elements turn red.
let DANGERTEMP = 40.0 let DANGERTEMP = 40.0
@Environment(\.openWindow) var openWindow @Environment(\.openWindow) var openWindow
@ObservedObject var printerManager = PrinterRequestManager.shared @ObservedObject var printerManager = PrinterRequestManager.shared
@State var printerObjectsQuery: PrinterObjectsQuery?
@State var printPercentage: Double = 0 @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 @Binding var currentMenuBarIcon: String
@State var hotendHotTemp: Bool = false @State var hotendHotTemp: Bool = false
@State var bedHotTemp: 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 // TODO: Use @published API data instead of instance state variable
var body: some View { var body: some View {
VStack { VStack {
// Printer Readouts // Printer Readouts
if let queryResults = printerManager.printerObjectsQuery { //if let printerStats = printerManager.printerStats {
Text(queryResults.result.status.print_stats.state.capitalized) if(printerManager.isConnected) {
.font(.title) VStack {
.padding(4) Text(printerManager.state.capitalized)
// Print information .font(.title)
HStack { .padding(4)
Image(systemName: "pencil.tip") // Print information
.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
HStack { HStack {
Image(systemName: "flame") Image(systemName: "pencil.tip")
.foregroundColor( hotendHotTemp ? .red : .white ) .rotationEffect(Angle(degrees: 180))
.opacity( hotendHotTemp ? 1.0 : 0.3 ) .offset(x: 5.5, y: 4)
Text("Hotend") .font(.system(size: 24))
.font(.headline) ProgressView(value: printerManager.progress, total: 1.0)
Spacer() .progressViewStyle(.linear)
Text("\(Int(queryResults.result.status.extruder.temperature))°C") .offset(x: 10)
Text("\(Int(printerManager.progress * 100))%")
.padding(2)
.padding([.leading], 8)
} }
// Bed temperature // Temperatures
HStack { HStack {
Image(systemName: "flame") // Hot-end temperature
.foregroundColor( bedHotTemp ? .red : .white ) HStack {
.opacity( bedHotTemp ? 1.0 : 0.3 ) Image(systemName: "flame")
Text("Plate") .foregroundColor( printerManager.extruderTemperature > DANGERTEMP ? .red : .white )
.font(.headline) .opacity( printerManager.extruderTemperature > DANGERTEMP ? 1.0 : 0.3 )
Spacer() Text("Hotend")
Text("\(Int(queryResults.result.status.heater_bed.temperature))°C") .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) //.frame(minWidth: 220, minHeight: 100)
// .overlay {
// if !printerManager.printerCommsOkay {
// RoundedRectangle(cornerRadius: 10, style: .circular)
// .foregroundColor(.black)
// .frame(minWidth: 300, minHeight: 100)
// .opacity(0.6)
// }
// }
// Footer information // Footer information
HStack { HStack {
Button { Button {
print("Button pressed") print("Button pressed")
openWindow(id: "soyuz_cfg") openWindow(id: "soyuz_cfg")
} label: { } label: {
Text("Server Config") Text("Printers")
.foregroundColor(.white) .foregroundColor(.white)
} }
Spacer() Spacer()
if(printerManager.printerCommsOkay) { if(printerManager.isConnected) {
Image(systemName: "network") Image(systemName: "network")
Text("Online") Text("Online")
} else { } else {
Image(systemName: "xmark") Image(systemName: "exclamationmark.triangle")
Text("Offline") Text("Offline")
} }
} }
.padding(4) .padding(2)
.frame(minWidth: 220, maxWidth: 250) .frame(minWidth: 220, maxWidth: 375)
.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)
}
}
} }
} }

View File

@ -6,10 +6,48 @@
// //
import SwiftUI import SwiftUI
import Network
struct PrinterConfigView: View { struct PrinterConfigView: View {
@ObservedObject var printerManager = PrinterRequestManager.shared
var body: some View { 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() PrinterConfigView()
} }
} }
extension NWEndpoint {
func toFriendlyString() -> String {
let regex = /\.(.+)/
let match = self.debugDescription.firstMatch(of: regex)
return self.debugDescription.replacingOccurrences(of: match!.0, with: "")
}
}

View File

@ -7,6 +7,7 @@
import Foundation import Foundation
// Root struct to decode for REST response
struct PrinterObjectsQuery: Decodable { struct PrinterObjectsQuery: Decodable {
let result: ResultsData let result: ResultsData
} }
@ -16,34 +17,76 @@ struct ResultsData: Decodable {
let status: StatusData 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 { struct StatusData: Decodable {
let virtual_sdcard: VirtualSDCardData let virtual_sdcard: VirtualSDCardData?
let extruder: ExtruderData let extruder: ExtruderData?
let print_stats: PrintStatsData let print_stats: PrintStatsData?
let heater_bed: HeaterBedData let heater_bed: HeaterBedData?
} }
struct VirtualSDCardData: Decodable { struct VirtualSDCardData: Decodable {
let file_path: String? let file_path: String?
let progress: Double let progress: Double?
let is_active: Bool let is_active: Bool?
} }
struct ExtruderData: Decodable { struct ExtruderData: Decodable {
let temperature: Double let temperature: Double?
let target: Double let target: Double?
let power: Double let power: Double?
} }
struct PrintStatsData: Decodable { struct PrintStatsData: Decodable {
let filename: String let filename: String?
let print_duration: Double let print_duration: Double?
let filament_used: Double let filament_used: Double?
let state: String let state: String?
} }
struct HeaterBedData: Decodable { struct HeaterBedData: Decodable {
let temperature: Double let temperature: Double?
let target: Double let target: Double?
let power: Double let power: Double?
} }

View File

@ -24,81 +24,117 @@ struct JsonRpcRequest: Codable {
} }
} }
//@MainActor
class PrinterRequestManager: ObservableObject, WebSocketDelegate { class PrinterRequestManager: ObservableObject, WebSocketDelegate {
func didReceive(event: Starscream.WebSocketEvent, client: Starscream.WebSocket) { let WEBSOCKET_TIMEOUT_INTERVAL: TimeInterval = 60.0
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!
static let shared = PrinterRequestManager() 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() { 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 // MARK: Bonjour browser initialization at instantiation
nwBrowser.browseResultsChangedHandler = { (newResults, changes) in nwBrowser.browseResultsChangedHandler = { (newResults, changes) in
print("[update] Results changed.") print("[update] Results changed.")
newResults.forEach { result in newResults.forEach { result in
print(result) print(result)
self.nwBrowserDiscoveredItems.append(result.endpoint) self.nwBrowserDiscoveredItems.append(result)
} }
} }
// State update handler // Bonjour browser state update handler
nwBrowser.stateUpdateHandler = { newState in nwBrowser.stateUpdateHandler = { newState in
switch newState { switch newState {
case .failed(let error): case .failed(let error):
print("[error] nwbrowser: \(error)") print("[error] nwbrowser: \(error)")
case .ready: case .ready:
print("[ready] nwbrowser") print("[ready] nwbrowser")
if let innerEndpoint = self.connection?.currentPath?.remoteEndpoint, case .hostPort(let host, let port) = innerEndpoint {
print("Connected to:", "\(host):\(port)")
}
case .setup: case .setup:
print("[setup] nwbrowser") print("[setup] nwbrowser")
default: default:
@ -109,22 +145,32 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate {
nwBrowser.start(queue: DispatchQueue.main) 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 // Momentarily connect/disconnects from the endpoint to retrieve the host/port
// calls private function openWebsocket to process the host/port // calls private function openWebsocket to process the host/port
func resolveBonjourHost(_ endpoint: NWEndpoint) { 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 = NWConnection(to: endpoint, using: .tcp)
connection.stateUpdateHandler = { [self] state in connection.stateUpdateHandler = { [self] state in
switch state { switch state {
case .ready: case .ready:
if let innerEndpoint = connection.currentPath?.remoteEndpoint, case .hostPort(let host, let port) = innerEndpoint { 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 hostString = "\(host)"
let regex = try! Regex("%en0") let regex = try! Regex("%(.+)")
let match = hostString.firstMatch(of: regex) let match = hostString.firstMatch(of: regex)
let sanitizedHost = hostString.replacingOccurrences(of: match!.0, with: "") let sanitizedHost = hostString.replacingOccurrences(of: match!.0, with: "")
print("[sanitized] Resolved \(sanitizedHost):\(port)") print("[sanitized] Resolved \(sanitizedHost):\(port)")
socketHost = sanitizedHost socketHost = sanitizedHost
socketPort = "\(port)" socketPort = "\(port)"
connection.cancel() connection.cancel()
@ -137,40 +183,91 @@ class PrinterRequestManager: ObservableObject, WebSocketDelegate {
connection.start(queue: .global()) 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 // Opens the websocket connection
// TODO: host and port should be function arguments probably maybe // TODO: host and port should be function arguments probably maybe
private func openWebsocket() { private func openWebsocket() {
if let host = socketHost, let port = socketPort { //let fullUrlString = "http://\(socketHost):\(socketPort)/websocket"
//let fullUrlString = "http://\(socketHost):\(socketPort)/websocket" var request = URLRequest(url: URL(string: "http://\(socketHost):\(socketPort)/websocket")!)
var request = URLRequest(url: URL(string: "http://\(host):\(port)/websocket")!) request.timeoutInterval = 5
request.timeoutInterval = 5 socket = WebSocket(request: request)
socket = WebSocket(request: request) socket!.delegate = self
socket.delegate = self socket!.connect()
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 // MARK: delegate callback for Starscream WebSocketClient
// TODO: Stop using this. func didReceive(event: Starscream.WebSocketEvent, client: Starscream.WebSocket) {
func queryPrinterStats() async { switch event {
guard let url = URL(string: "http://10.0.21.39/printer/objects/query?extruder&virtual_sdcard&print_stats&heater_bed") else { case .connected(let headers):
fatalError("Missing URL") isConnected = true
} print("websocket is connected: \(headers)")
writeToDebugLog("Connected to WebSocket")
let urlRequest = URLRequest(url: url) let jsonRpcRequest = JsonRpcRequest(method: "printer.objects.subscribe",
do { params: ["objects":
let (data, response) = try await URLSession.shared.data(for: urlRequest) ["extruder": nil,
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { "virtual_sdcard": nil,
print("Error with response.") "heater_bed": nil,
return "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 // Check for RPC updates
let decoder = JSONDecoder() if let updateResponse = try? JSONDecoder().decode(jsonRpcUpdate.self, from: Data(string.utf8)) {
printerObjectsQuery = try decoder.decode(PrinterObjectsQuery.self, from: data) self.parse_update(updateResponse)
printerCommsOkay = true }
} catch { case .binary(let data):
print("Exception thrown: \(error)") self.writeToDebugLog(String(data: data, encoding: .utf8)!)
printerCommsOkay = false 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)")
} }
} }
} }

View File

@ -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
}
}
}

View File

@ -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.")
}
}