Rename project to Soyuz

This commit is contained in:
Madeline
2023-02-21 00:42:27 -05:00
parent d084ebb1ac
commit 65daae4bf7
25 changed files with 109 additions and 87 deletions

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,58 @@
{
"images" : [
{
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

26
Soyuz/Info.plist Normal file
View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>soyuz</string>
<key>CFBundleURLSchemes</key>
<array>
<string>soyuz</string>
</array>
</dict>
</array>
<key>NSBonjourServices</key>
<array>
<string>_moonraker._tcp.</string>
<string>_http._tcp.</string>
</array>
<key>NSServices</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>KlipperMon.xcdatamodel</string>
</dict>
</plist>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<elements>
<element name="Item" positionX="-63" positionY="-18" width="128" height="44"/>
</elements>
</model>

56
Soyuz/Persistence.swift Normal file
View File

@@ -0,0 +1,56 @@
//
// Persistence.swift
// KlipperMon
//
// Created by maddiefuzz on 2/7/23.
//
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "KlipperMon")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
/*
Typical reasons for an error here include:
* The parent directory does not exist, cannot be created, or disallows writing.
* The persistent store is not accessible, due to permissions or data protection when the device is locked.
* The device is out of space.
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,67 @@
//
// PrinterConfigView.swift
// KlipperMon
//
// Created by maddiefuzz on 2/8/23.
//
import SwiftUI
import Network
struct PrinterConfigView: View {
@ObservedObject var printerManager = PrinterRequestManager.shared
var body: some View {
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)
}
}
}
struct PrinterConfigView_Previews: PreviewProvider {
static var previews: some View {
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

@@ -0,0 +1,92 @@
//
// PrinterObjectsQuery.swift
// KlipperMon
//
// Created by maddiefuzz on 2/7/23.
//
import Foundation
// Root struct to decode for REST response
struct PrinterObjectsQuery: Decodable {
let result: ResultsData
}
struct ResultsData: Decodable {
let eventtime: Double
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?
}
struct VirtualSDCardData: Decodable {
let file_path: String?
let progress: Double?
let is_active: Bool?
}
struct ExtruderData: Decodable {
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?
}
struct HeaterBedData: Decodable {
let temperature: Double?
let target: Double?
let power: Double?
}

View File

@@ -0,0 +1,273 @@
//
// PrinterRequestManager.swift
// KlipperMon
//
// Created by maddiefuzz on 2/7/23.
//
import Foundation
import Network
import Starscream
struct JsonRpcRequest: Codable {
var jsonrpc = "2.0"
let method: String
let params: [String: [String: String?]]
var id = 1
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(jsonrpc, forKey: .jsonrpc)
try container.encode(method, forKey: .method)
try container.encode(params, forKey: .params)
try container.encode(id, forKey: .id)
}
}
//@MainActor
class PrinterRequestManager: ObservableObject, WebSocketDelegate {
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)
}
}
// Bonjour browser state update handler
nwBrowser.stateUpdateHandler = { newState in
switch newState {
case .failed(let error):
print("[error] nwbrowser: \(error)")
case .ready:
print("[ready] nwbrowser")
case .setup:
print("[setup] nwbrowser")
default:
break
}
}
// Start up the bonjour browser, get results and process them in the update handler
nwBrowser.start(queue: DispatchQueue.main)
}
// 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 {
let hostPortDebugOutput = "Connected to \(host):\(port)"
print(hostPortDebugOutput)
writeToDebugLog(hostPortDebugOutput)
let hostString = "\(host)"
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()
self.openWebsocket()
}
default:
break
}
}
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() {
//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()
}
}
// 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)
}
// 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)")
}
}
}

44
Soyuz/PrinterStats.swift Normal file
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
}
}
}

12
Soyuz/Soyuz.entitlements Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

35
Soyuz/SoyuzApp.swift Normal file
View File

@@ -0,0 +1,35 @@
//
// KlipperMonApp.swift
// KlipperMon
//
// Created by maddiefuzz on 2/7/23.
//
import SwiftUI
@main
struct SoyuzApp: App {
let persistenceController = PersistenceController.shared
@State var currentIcon = "move.3d"
var body: some Scene {
// WindowGroup(id: "floating-stats") {
// KlipperMonMenuBarExtraView(currentMenuBarIcon: $currentIcon)
// .environment(\.managedObjectContext, persistenceController.container.viewContext)
// }
WindowGroup("Configuration", id: "soyuz_cfg", content: {
PrinterConfigView()
//.frame(minWidth: 300, maxWidth: 600, minHeight: 60, maxHeight: 100)
})
.windowResizability(.contentSize)
MenuBarExtra("Soyuz", systemImage: currentIcon) {
SoyuzMenuBarExtraView(currentMenuBarIcon: $currentIcon)
.padding([.top, .leading, .trailing], 8)
.padding([.bottom], 6)
}
.menuBarExtraStyle(.window)
}
}

View File

@@ -0,0 +1,107 @@
//
// KlipperMonMenuBarExtraView.swift
// KlipperMon
//
// Created by maddiefuzz on 2/7/23.
//
import SwiftUI
import AppKit
import Network
struct SoyuzMenuBarExtraView: 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 printPercentage: Double = 0
@Binding var currentMenuBarIcon: String
@State var hotendHotTemp: Bool = false
@State var bedHotTemp: Bool = false
// TODO: Use @published API data instead of instance state variable
var body: some View {
VStack {
// Printer Readouts
//if let printerStats = printerManager.printerStats {
if(printerManager.isConnected) {
VStack {
Text(printerManager.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: printerManager.progress, total: 1.0)
.progressViewStyle(.linear)
.offset(x: 10)
Text("\(Int(printerManager.progress * 100))%")
.padding(2)
.padding([.leading], 8)
}
// Temperatures
HStack {
// 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()
}
}
}
//.frame(minWidth: 220, minHeight: 100)
// Footer information
HStack {
Button {
print("Button pressed")
openWindow(id: "soyuz_cfg")
} label: {
Text("Printers")
.foregroundColor(.white)
}
Spacer()
if(printerManager.isConnected) {
Image(systemName: "network")
Text("Online")
} else {
Image(systemName: "exclamationmark.triangle")
Text("Offline")
}
}
.padding(2)
.frame(minWidth: 220, maxWidth: 375)
}
}
struct KlipperMonMenuBarExtraView_Previews: PreviewProvider {
@State static var currentMenuBarIcon = "move.3d"
static var previews: some View {
SoyuzMenuBarExtraView(currentMenuBarIcon: $currentMenuBarIcon)
}
}