Skip to content

Commit

Permalink
feat: User configurable serial ports (#55)
Browse files Browse the repository at this point in the history
This change adds automatic serial port monitoring using IOKit and
introduces a number of hacks to the custom branch of plptools to allow
ncpd to be stopped and started again within the same process. These
hacks are currently required due to the use of global state in ncpd—it
really expects to be run as a single standalone process and respawned
whenever any configuration changes. Ideally, state would be better
contained with support for something like SIGHUP to reload configuration
and restart.
  • Loading branch information
jbmorley authored Jun 26, 2024
1 parent 3684499 commit 3dbc8cf
Show file tree
Hide file tree
Showing 13 changed files with 334 additions and 77 deletions.
18 changes: 13 additions & 5 deletions Reconnect.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
D806342B2C27F87100DEA6DA /* plpftp in Frameworks */ = {isa = PBXBuildFile; productRef = D806342A2C27F87100DEA6DA /* plpftp */; };
D813FF152C26E869009CF0DC /* plptools-license in Resources */ = {isa = PBXBuildFile; fileRef = D813FF142C26E869009CF0DC /* plptools-license */; };
D8184C472C253C59008FA79B /* ApplicationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8184C462C253C59008FA79B /* ApplicationModel.swift */; };
D8184C4B2C253D73008FA79B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8184C4A2C253D73008FA79B /* SettingsView.swift */; };
D81A0C162C27A90000EC2929 /* ncp in Frameworks */ = {isa = PBXBuildFile; productRef = D81A0C152C27A90000EC2929 /* ncp */; };
D822EA132C2BA92E008A4BAA /* SerialDeviceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D822EA122C2BA92E008A4BAA /* SerialDeviceMonitor.swift */; };
D83658B72C29596F00B45693 /* NavigationStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83658B62C29596F00B45693 /* NavigationStackTests.swift */; };
D83658B92C298C4F00B45693 /* NavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83658B82C298C4F00B45693 /* NavigationStack.swift */; };
D83658BB2C29A09300B45693 /* HistoryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83658BA2C29A09300B45693 /* HistoryItemView.swift */; };
Expand Down Expand Up @@ -58,7 +58,7 @@
/* Begin PBXFileReference section */
D813FF142C26E869009CF0DC /* plptools-license */ = {isa = PBXFileReference; lastKnownFileType = text; path = "plptools-license"; sourceTree = "<group>"; };
D8184C462C253C59008FA79B /* ApplicationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationModel.swift; sourceTree = "<group>"; };
D8184C4A2C253D73008FA79B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
D822EA122C2BA92E008A4BAA /* SerialDeviceMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialDeviceMonitor.swift; sourceTree = "<group>"; };
D83658B62C29596F00B45693 /* NavigationStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStackTests.swift; sourceTree = "<group>"; };
D83658B82C298C4F00B45693 /* NavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationStack.swift; sourceTree = "<group>"; };
D83658BA2C29A09300B45693 /* HistoryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -124,7 +124,6 @@
children = (
D8184C462C253C59008FA79B /* ApplicationModel.swift */,
D886DA752C29343900E84BDA /* BrowserModel.swift */,
D83658B82C298C4F00B45693 /* NavigationStack.swift */,
D8C080792C1D7D8D003128AB /* ReconnectError.swift */,
);
path = Model;
Expand All @@ -136,12 +135,20 @@
D886DA732C28CDAD00E84BDA /* BrowserView.swift */,
D83658BA2C29A09300B45693 /* HistoryItemView.swift */,
D8D3E7A02C25410E003E696D /* MainMenu.swift */,
D8184C4A2C253D73008FA79B /* SettingsView.swift */,
D89B5E8D2C2AA8680014A5B6 /* Sidebar.swift */,
);
path = Views;
sourceTree = "<group>";
};
D822EA0F2C2BA305008A4BAA /* Utilities */ = {
isa = PBXGroup;
children = (
D83658B82C298C4F00B45693 /* NavigationStack.swift */,
D822EA122C2BA92E008A4BAA /* SerialDeviceMonitor.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
D84964CD2C1BFCB600405656 = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -175,6 +182,7 @@
D8184C482C253C6F008FA79B /* Model */,
D886DA772C29345E00E84BDA /* PLP */,
D84964DF2C1BFCB700405656 /* Preview Content */,
D822EA0F2C2BA305008A4BAA /* Utilities */,
D8184C492C253D62008FA79B /* Views */,
);
path = Reconnect;
Expand Down Expand Up @@ -398,8 +406,8 @@
D8E31EB52C26E10900350082 /* Licensable.swift in Sources */,
D886DA742C28CDAD00E84BDA /* BrowserView.swift in Sources */,
D83658B92C298C4F00B45693 /* NavigationStack.swift in Sources */,
D822EA132C2BA92E008A4BAA /* SerialDeviceMonitor.swift in Sources */,
D87AAC8A2C27EB070091F442 /* FileServer.swift in Sources */,
D8184C4B2C253D73008FA79B /* SettingsView.swift in Sources */,
D8184C472C253C59008FA79B /* ApplicationModel.swift in Sources */,
D89B5E8E2C2AA8680014A5B6 /* Sidebar.swift in Sources */,
D886DA762C29343900E84BDA /* BrowserModel.swift in Sources */,
Expand Down
1 change: 0 additions & 1 deletion Reconnect/Extensions/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,5 @@ extension URL {

static let about = URL(string: "x-reconnect://about")!
static let browser = URL(string: "x-reconnect://browser")!
static let settings = URL(string: "x-reconnect://settings")!

}
87 changes: 80 additions & 7 deletions Reconnect/Model/ApplicationModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,27 +20,100 @@ import SwiftUI

import Interact

@Observable
class ApplicationModel {
@MainActor @Observable
class ApplicationModel: NSObject {

struct SerialDevice: Identifiable {

var id: String {
return path
}

var path: String
var available: Bool
var enabled: Binding<Bool>
}

enum SettingsKey: String {
case device
case selectedDevices
}

@MainActor var device: String {
var isConnected: Bool = false

var devices: [SerialDevice] {
return connectedDevices.union(selectedDevices)
.map { device in
let binding: Binding<Bool> = Binding {
return self.selectedDevices.contains(device)
} set: { newValue in
if newValue {
self.selectedDevices.insert(device)
} else {
self.selectedDevices.remove(device)
}
}
return SerialDevice(path: device,
available: connectedDevices.contains(device),
enabled: binding)
}
.sorted { device1, device2 in
return device1.path.localizedStandardCompare(device2.path) == .orderedAscending
}
}

private var selectedDevices: Set<String> {
didSet {
keyedDefaults.set(Array(selectedDevices), forKey: .selectedDevices)
update()
}
}

private var connectedDevices: Set<String> = [] {
didSet {
keyedDefaults.set(device, forKey: .device)
update()
}
}

private let keyedDefaults = KeyedDefaults<SettingsKey>()
private let server: Server = Server()
private let serialDeviceMonitor = SerialDeviceMonitor()

@MainActor init() {
device = keyedDefaults.string(forKey: .device, default: "")
override init() {
selectedDevices = Set(keyedDefaults.object(forKey: .selectedDevices) as? Array<String> ?? [])
super.init()
server.delegate = self
serialDeviceMonitor.delegate = self
server.start()
serialDeviceMonitor.start()
}

@MainActor func quit() {
NSApplication.shared.terminate(nil)
}

func update() {
server.setDevices(selectedDevices.intersection(connectedDevices).sorted())
}

}

extension ApplicationModel: ServerDelegate {

func server(server: Server, didChangeConnectionState isConnected: Bool) {
self.isConnected = isConnected
}

}

extension ApplicationModel: SerialDeviceMonitorDelegate {

func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didAddDevice device: String) {
connectedDevices.insert(device)

}

func serialDeviceMonitor(serialDeviceMonitor: SerialDeviceMonitor, didRemoveDevice device: String) {
connectedDevices.remove(device)
}

}
9 changes: 3 additions & 6 deletions Reconnect/Model/BrowserModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class BrowserModel {
return name(for: path)
}

let fileServer = FileServer(host: "127.0.0.1", port: 7501)
let fileServer: FileServer

var drives: [FileServer.DriveInfo] = []
var files: [FileServer.DirectoryEntry] = []
Expand All @@ -48,11 +48,8 @@ class BrowserModel {

private var navigationStack = NavigationStack()

init() {
guard fileServer.connect() else {
lastError = ReconnectError.unknown
return
}
init(fileServer: FileServer) {
self.fileServer = fileServer
}

func start() async {
Expand Down
13 changes: 10 additions & 3 deletions Reconnect/PLP/FileServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,15 @@ class FileServer {
self.port = port
}

func connect() -> Bool {
return workQueue.sync {
return self.client.connect(self.host, self.port)
private func syncQueue_connect() throws {
guard self.client.connect(self.host, self.port) else {
throw ReconnectError.unknown
}
}

private func syncQueue_dir(path: String) throws -> [DirectoryEntry] {
dispatchPrecondition(condition: .onQueue(workQueue))
try syncQueue_connect()
var details = PlpDir()
client.dir(path, &details)

Expand Down Expand Up @@ -158,6 +159,7 @@ class FileServer {

func syncQueue_copyFile(fromRemotePath remoteSourcePath: String, toLocalPath localDestinationPath: String) throws {
dispatchPrecondition(condition: .onQueue(workQueue))
try syncQueue_connect()
let result = client.copyFromPsion(remoteSourcePath, localDestinationPath, nil) { context, status in
print("progress = \(status)")
return 1 // 0 is cancel
Expand All @@ -182,6 +184,7 @@ class FileServer {

func syncQueue_mkdir(path: String) throws {
dispatchPrecondition(condition: .onQueue(workQueue))
try syncQueue_connect()
let result = client.mkdir(path)
guard result.rawValue == 0 else {
throw ReconnectError.rfsvError(result)
Expand All @@ -203,6 +206,7 @@ class FileServer {

func syncQueue_rmdir(path: String) throws {
dispatchPrecondition(condition: .onQueue(workQueue))
try syncQueue_connect()
let result = client.rmdir(path)
guard result.rawValue == 0 else {
throw ReconnectError.rfsvError(result)
Expand All @@ -224,6 +228,7 @@ class FileServer {

func syncQueue_remove(path: String) throws {
dispatchPrecondition(condition: .onQueue(workQueue))
try syncQueue_connect()
let result = client.remove(path)
guard result.rawValue == 0 else {
throw ReconnectError.rfsvError(result)
Expand All @@ -245,6 +250,7 @@ class FileServer {

func syncQueue_devlist() throws -> [String] {
dispatchPrecondition(condition: .onQueue(workQueue))
try syncQueue_connect()
var devbits: UInt32 = 0
let result = client.devlist(&devbits)
guard result.rawValue == 0 else {
Expand All @@ -262,6 +268,7 @@ class FileServer {

func syncQueue_devinfo(drive: String) throws -> DriveInfo {
dispatchPrecondition(condition: .onQueue(workQueue))
try syncQueue_connect()
let d = drive.cString(using: .ascii)!.first!
var driveInfo = PlpDrive()
let result = client.devinfo(d, &driveInfo)
Expand Down
75 changes: 68 additions & 7 deletions Reconnect/PLP/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,43 @@ import Foundation

import ncp

@Observable
protocol ServerDelegate: NSObject {

func server(server: Server, didChangeConnectionState isConnected: Bool)

}

class Server {

var isConnected: Bool = false
weak var delegate: ServerDelegate? = nil

var lock = NSLock()
var threadID: pthread_t? = nil // Synchronized with lock.
var devices: [String] = [] // Synchronized with lock.

func device() -> String {
print("Getting device...")
while true {
let devices = lock.withLock {
return self.devices
}
if let device = devices.first {
return device
}
print("Waiting for devices...")
sleep(1)
}
}

func threadEntryPoint() {

setup_signal_handlers()

// TODO: Maybe this shouldn't be a member?
lock.withLock {
threadID = pthread_self()
}

let context = Unmanaged.passRetained(self).toOpaque()
let callback: statusCallback_t = { context, status in
guard let context else {
Expand All @@ -35,20 +65,51 @@ class Server {
print("status = \(status)")
let server = Unmanaged<Server>.fromOpaque(context).takeUnretainedValue()
DispatchQueue.main.sync {
server.isConnected = status == 1 ? true : false
let isConnected = status == 1 ? true : false
server.delegate?.server(server: server, didChangeConnectionState: isConnected)
}
}

// let device = "/dev/tty.usbserial-AL00AYCG"
let device = "/dev/tty.usbserial-A91MGK6M"

ncpd(7501, 115200, "127.0.0.1", device, 0, callback, context)
while true {
let device = self.device()
print("Using device \(device)...")
ncpd(7501, 115200, "127.0.0.1", device, 0x0000, callback, context)
DispatchQueue.main.async {
self.delegate?.server(server: self, didChangeConnectionState: false)
}
print("ncpd ended")
}
}

init() {
// Create a new thread and start it
}

func start() {
// TODO: ONLY DO THIS ONCE!
let thread = Thread(block: threadEntryPoint)
thread.start()

// TODO: This should probably block until we're ready??
}

func setDevices(_ devices: [String]) {
// SIGHUP?
guard let threadID = lock.withLock({
return self.threadID
}) else {
return
}

print("Setting devices \(devices)")

lock.withLock {
self.devices = devices
}

print("Signalling thread...")
pthread_kill(threadID, SIGINT)
print("DONE?")
}

}
Loading

0 comments on commit 3dbc8cf

Please sign in to comment.