- Scene = control config + config per node; screens matched by name, then position - Remote module: length-prefixed JSON messages, NodeServer (Bonjour on the technical interface), NodeBrowser with auto-reconnect - ChannelSelection moves to MusubiCore Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
cf561d410c
commit
02c06f7105
@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
/// A screen identified across restarts and machines: by name first, position as fallback.
|
||||
/// (CGDirectDisplayIDs differ between Macs and can change after a reboot.)
|
||||
public struct ScreenRef: Codable, Hashable, Sendable {
|
||||
public var name: String
|
||||
/// Position in the system's screen list (0 = main screen).
|
||||
public var index: Int
|
||||
|
||||
public init(name: String, index: Int) {
|
||||
self.name = name
|
||||
self.index = index
|
||||
}
|
||||
}
|
||||
|
||||
/// One NDI source fullscreen on one screen.
|
||||
public struct VideoAssignment: Codable, Hashable, Sendable {
|
||||
public var screen: ScreenRef
|
||||
public var source: Source
|
||||
|
||||
public init(screen: ScreenRef, source: Source) {
|
||||
self.screen = screen
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
/// One audio source on one output channel.
|
||||
public struct AudioAssignment: Codable, Hashable, Sendable {
|
||||
public enum SourceSpec: Codable, Hashable, Sendable {
|
||||
case ndi(name: String)
|
||||
/// Input device by UID; the name is the fallback when the UID changes (e.g. other USB port).
|
||||
case input(uid: String, name: String)
|
||||
}
|
||||
|
||||
public var source: SourceSpec
|
||||
public var deviceUID: String
|
||||
public var deviceName: String
|
||||
public var channel: Int // 0-based
|
||||
public var selection: ChannelSelection
|
||||
public var gainDB: Float
|
||||
public var muted: Bool
|
||||
|
||||
public init(source: SourceSpec, deviceUID: String, deviceName: String, channel: Int,
|
||||
selection: ChannelSelection = .mono, gainDB: Float = 0, muted: Bool = false) {
|
||||
self.source = source
|
||||
self.deviceUID = deviceUID
|
||||
self.deviceName = deviceName
|
||||
self.channel = channel
|
||||
self.selection = selection
|
||||
self.gainDB = gainDB
|
||||
self.muted = muted
|
||||
}
|
||||
|
||||
/// Identity of the route (what goes where), without its settings.
|
||||
public var key: String {
|
||||
let sourceKey = switch source {
|
||||
case .ndi(let name): "ndi:\(name)"
|
||||
case .input(let uid, _): "input:\(uid)"
|
||||
}
|
||||
return "\(sourceKey)→\(deviceUID)#\(channel)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one Mac outputs.
|
||||
public struct MachineConfig: Codable, Hashable, Sendable {
|
||||
public var video: [VideoAssignment]
|
||||
public var audio: [AudioAssignment]
|
||||
|
||||
public init(video: [VideoAssignment] = [], audio: [AudioAssignment] = []) {
|
||||
self.video = video
|
||||
self.audio = audio
|
||||
}
|
||||
|
||||
public static let empty = MachineConfig()
|
||||
}
|
||||
|
||||
/// A complete setup of the control Mac and its nodes, e.g. "OP-Live".
|
||||
public struct Scene: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: UUID
|
||||
public var name: String
|
||||
public var control: MachineConfig
|
||||
/// Node name (computer name) → what that node shows.
|
||||
public var nodes: [String: MachineConfig]
|
||||
|
||||
public init(id: UUID = UUID(), name: String, control: MachineConfig = .empty, nodes: [String: MachineConfig] = [:]) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.control = control
|
||||
self.nodes = nodes
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
/// Finds the connected screen a `ScreenRef` means.
|
||||
public enum ScreenMatching {
|
||||
/// - Parameter names: names of the connected screens in system order.
|
||||
/// - Returns: index into `names`, or nil if the screen is not connected.
|
||||
public static func index(of ref: ScreenRef, in names: [String]) -> Int? {
|
||||
let sameName = names.indices.filter { names[$0] == ref.name }
|
||||
switch sameName.count {
|
||||
case 1:
|
||||
return sameName[0]
|
||||
case 0:
|
||||
// Renamed or different monitor model: only trust the position if the count still fits.
|
||||
return nil
|
||||
default:
|
||||
// Identical monitors: prefer the remembered position, else the first free match.
|
||||
return sameName.contains(ref.index) ? ref.index : sameName.first
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves all assignments; screens claimed twice keep the first assignment.
|
||||
public static func resolve(_ assignments: [VideoAssignment], in names: [String]) -> [Int: Source] {
|
||||
var result: [Int: Source] = [:]
|
||||
for assignment in assignments {
|
||||
guard let index = index(of: assignment.screen, in: names), result[index] == nil else { continue }
|
||||
result[index] = assignment.source
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// What to stop and start to get from the current state to the desired one.
|
||||
public enum Reconcile {
|
||||
public struct Plan<Key: Hashable, Value: Equatable> {
|
||||
public var stop: [Key]
|
||||
public var start: [Key: Value]
|
||||
}
|
||||
|
||||
public static func plan<Key: Hashable, Value: Equatable>(current: [Key: Value], desired: [Key: Value]) -> Plan<Key, Value> {
|
||||
let stop = current.keys.filter { desired[$0] == nil }
|
||||
let start = desired.filter { current[$0.key] != $0.value }
|
||||
return Plan(stop: stop, start: start)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Network interfaces that services can be bound to (e.g. en7 = technical network).
|
||||
public enum NetworkInterfaces {
|
||||
public struct Info: Hashable, Sendable, Identifiable {
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let kind: String
|
||||
}
|
||||
|
||||
/// Currently available interfaces (wired, Wi-Fi, other), excluding loopback.
|
||||
public static func available() async -> [Info] {
|
||||
await withCheckedContinuation { continuation in
|
||||
let monitor = NWPathMonitor()
|
||||
let once = OnceFlag()
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard once.claim() else { return }
|
||||
monitor.cancel()
|
||||
continuation.resume(returning: path.availableInterfaces
|
||||
.filter { $0.type != .loopback }
|
||||
.map { Info(name: $0.name, kind: describe($0.type)) })
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
|
||||
static func interface(named name: String) async -> NWInterface? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let monitor = NWPathMonitor()
|
||||
let once = OnceFlag()
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard once.claim() else { return }
|
||||
monitor.cancel()
|
||||
continuation.resume(returning: path.availableInterfaces.first { $0.name == name })
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
|
||||
private static func describe(_ type: NWInterface.InterfaceType) -> String {
|
||||
switch type {
|
||||
case .wiredEthernet: "Ethernet"
|
||||
case .wifi: "WLAN"
|
||||
default: "Andere"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class OnceFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
/// One framed message connection (either side). Callbacks arrive on the main actor.
|
||||
final class Link: @unchecked Sendable {
|
||||
let connection: NWConnection
|
||||
private let queue: DispatchQueue
|
||||
private var framer = MessageFramer() // queue only
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote")
|
||||
|
||||
var onMessage: (@MainActor (RemoteMessage) -> Void)?
|
||||
var onState: (@MainActor (NWConnection.State) -> Void)?
|
||||
|
||||
init(connection: NWConnection, queue: DispatchQueue) {
|
||||
self.connection = connection
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
func start() {
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
let handler = onState
|
||||
Task { @MainActor in handler?(state) }
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
receive()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
func send(_ message: RemoteMessage) {
|
||||
do {
|
||||
let data = try MessageFramer.encode(message)
|
||||
connection.send(content: data, completion: .contentProcessed { [log] error in
|
||||
if let error { log.error("Send failed: \(error.localizedDescription, privacy: .public)") }
|
||||
})
|
||||
} catch {
|
||||
log.error("Encode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
if let data, !data.isEmpty {
|
||||
do {
|
||||
let messages = try framer.append(data)
|
||||
let handler = onMessage
|
||||
if !messages.isEmpty {
|
||||
Task { @MainActor in messages.forEach { handler?($0) } }
|
||||
}
|
||||
} catch {
|
||||
log.error("Framing error, closing: \(String(describing: error), privacy: .public)")
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
if isComplete || error != nil {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
receive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NWParameters {
|
||||
/// TCP bound to one interface, with Bonjour peer-to-peer off (technical network only).
|
||||
static func musubi(interface: NWInterface?) -> NWParameters {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.includePeerToPeer = false
|
||||
if let interface { parameters.requiredInterface = interface }
|
||||
if let tcp = parameters.defaultProtocolStack.transportProtocol as? NWProtocolTCP.Options {
|
||||
tcp.enableKeepalive = true
|
||||
tcp.keepaliveIdle = 2
|
||||
tcp.keepaliveInterval = 1
|
||||
tcp.keepaliveCount = 3
|
||||
tcp.noDelay = true
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import MusubiCore
|
||||
|
||||
public enum RemoteProtocol {
|
||||
public static let serviceType = "_musubi._tcp"
|
||||
public static let version = 1
|
||||
}
|
||||
|
||||
/// State of one screen on a node, as reported to the control.
|
||||
public struct ScreenStatus: Codable, Hashable, Sendable, Identifiable {
|
||||
public var id: ScreenRef { screen }
|
||||
public var screen: ScreenRef
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
public var refreshRate: Double
|
||||
public var output: OutputStatus?
|
||||
|
||||
public init(screen: ScreenRef, width: Int, height: Int, refreshRate: Double, output: OutputStatus?) {
|
||||
self.screen = screen
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.refreshRate = refreshRate
|
||||
self.output = output
|
||||
}
|
||||
}
|
||||
|
||||
public struct OutputStatus: Codable, Hashable, Sendable {
|
||||
public enum State: String, Codable, Sendable { case connecting, live, stalled }
|
||||
|
||||
public var source: Source
|
||||
public var state: State
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
public var frameRate: Double
|
||||
public var lostFrames: Int64
|
||||
public var lossRate: Double
|
||||
|
||||
public init(source: Source, state: State, width: Int, height: Int, frameRate: Double, lostFrames: Int64, lossRate: Double) {
|
||||
self.source = source
|
||||
self.state = state
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.frameRate = frameRate
|
||||
self.lostFrames = lostFrames
|
||||
self.lossRate = lossRate
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeStatus: Codable, Hashable, Sendable {
|
||||
public var screens: [ScreenStatus]
|
||||
/// What the node currently outputs (so a control can adopt it, e.g. when saving a scene).
|
||||
public var config: MachineConfig
|
||||
public var cpuPercent: Double
|
||||
public var sleepBlocked: Bool
|
||||
|
||||
public init(screens: [ScreenStatus], config: MachineConfig, cpuPercent: Double, sleepBlocked: Bool) {
|
||||
self.screens = screens
|
||||
self.config = config
|
||||
self.cpuPercent = cpuPercent
|
||||
self.sleepBlocked = sleepBlocked
|
||||
}
|
||||
}
|
||||
|
||||
public enum RemoteMessage: Codable, Hashable, Sendable {
|
||||
/// First message in both directions.
|
||||
case hello(name: String, role: Role, version: Int)
|
||||
/// Node → control, once per second.
|
||||
case status(NodeStatus)
|
||||
/// Control → node: replace the whole configuration.
|
||||
case apply(MachineConfig)
|
||||
/// Control → node: show one source on one screen.
|
||||
case show(VideoAssignment)
|
||||
/// Control → node: stop the output on one screen.
|
||||
case stop(ScreenRef)
|
||||
}
|
||||
|
||||
/// Length-prefixed JSON framing: 4-byte big-endian length, then the JSON body.
|
||||
public struct MessageFramer: Sendable {
|
||||
public static let maxMessageSize = 4 * 1024 * 1024
|
||||
private var buffer = Data()
|
||||
|
||||
public init() {}
|
||||
|
||||
public static func encode(_ message: RemoteMessage) throws -> Data {
|
||||
let body = try JSONEncoder().encode(message)
|
||||
var length = UInt32(body.count).bigEndian
|
||||
return Data(bytes: &length, count: 4) + body
|
||||
}
|
||||
|
||||
public enum FramingError: Error { case tooLarge(Int) }
|
||||
|
||||
/// Appends received bytes and returns all complete messages. Malformed JSON is skipped.
|
||||
public mutating func append(_ data: Data) throws -> [RemoteMessage] {
|
||||
buffer.append(data)
|
||||
var messages: [RemoteMessage] = []
|
||||
while buffer.count >= 4 {
|
||||
let length = buffer.prefix(4).reduce(0) { Int($0) << 8 | Int($1) }
|
||||
guard length <= Self.maxMessageSize else { throw FramingError.tooLarge(length) }
|
||||
guard buffer.count >= 4 + length else { break }
|
||||
let body = buffer.subdata(in: buffer.startIndex + 4 ..< buffer.startIndex + 4 + length)
|
||||
buffer.removeFirst(4 + length)
|
||||
if let message = try? JSONDecoder().decode(RemoteMessage.self, from: body) {
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import MusubiCore
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Control side: finds nodes via Bonjour and keeps a connection to each of them.
|
||||
@MainActor @Observable
|
||||
public final class NodeBrowser {
|
||||
@MainActor @Observable
|
||||
public final class Node: Identifiable {
|
||||
public nonisolated let id: String // Bonjour service name = node name
|
||||
public fileprivate(set) var isConnected = false
|
||||
public fileprivate(set) var status: NodeStatus?
|
||||
fileprivate var link: Link?
|
||||
fileprivate var endpoint: NWEndpoint?
|
||||
fileprivate var retryTask: Task<Void, Never>?
|
||||
|
||||
init(name: String) { id = name }
|
||||
|
||||
public var name: String { id }
|
||||
|
||||
public func send(_ message: RemoteMessage) {
|
||||
link?.send(message)
|
||||
}
|
||||
}
|
||||
|
||||
public private(set) var nodes: [Node] = []
|
||||
public private(set) var error: String?
|
||||
|
||||
/// A node said hello (first contact or after a reconnect).
|
||||
public var onNodeConnected: ((Node) -> Void)?
|
||||
|
||||
private let name: String
|
||||
private var browser: NWBrowser?
|
||||
private var interface: NWInterface?
|
||||
private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.control")
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote")
|
||||
|
||||
public init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public func start(interfaceName: String) async {
|
||||
stop()
|
||||
guard let interface = await NetworkInterfaces.interface(named: interfaceName) else {
|
||||
error = "Technik-Schnittstelle \(interfaceName) nicht gefunden."
|
||||
return
|
||||
}
|
||||
self.interface = interface
|
||||
let browser = NWBrowser(for: .bonjour(type: RemoteProtocol.serviceType, domain: nil), using: .musubi(interface: interface))
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
let found = results.compactMap { result -> (String, NWEndpoint)? in
|
||||
guard case .service(let name, _, _, _) = result.endpoint else { return nil }
|
||||
return (name, result.endpoint)
|
||||
}
|
||||
Task { @MainActor in self?.update(found) }
|
||||
}
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
if case .failed(let failure) = state {
|
||||
Task { @MainActor in self?.error = "Knotensuche gestört: \(failure.localizedDescription)" }
|
||||
}
|
||||
}
|
||||
browser.start(queue: queue)
|
||||
self.browser = browser
|
||||
error = nil
|
||||
log.info("Control: browsing on \(interfaceName, privacy: .public)")
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
browser?.cancel()
|
||||
browser = nil
|
||||
for node in nodes {
|
||||
node.retryTask?.cancel()
|
||||
node.link?.cancel()
|
||||
}
|
||||
nodes = []
|
||||
}
|
||||
|
||||
public func node(named name: String) -> Node? {
|
||||
nodes.first { $0.id == name }
|
||||
}
|
||||
|
||||
private func update(_ found: [(String, NWEndpoint)]) {
|
||||
for (name, endpoint) in found where name != self.name {
|
||||
let node = self.node(named: name) ?? {
|
||||
let node = Node(name: name)
|
||||
nodes.append(node)
|
||||
nodes.sort { $0.id.localizedStandardCompare($1.id) == .orderedAscending }
|
||||
return node
|
||||
}()
|
||||
node.endpoint = endpoint
|
||||
if node.link == nil { connect(node) }
|
||||
}
|
||||
// Nodes that vanished from Bonjour stay listed while connected; the connection decides.
|
||||
}
|
||||
|
||||
private func connect(_ node: Node) {
|
||||
guard let endpoint = node.endpoint else { return }
|
||||
node.retryTask?.cancel()
|
||||
let link = Link(connection: NWConnection(to: endpoint, using: .musubi(interface: interface)), queue: queue)
|
||||
link.onMessage = { [weak self, weak node] message in
|
||||
guard let self, let node, node.link === link else { return }
|
||||
received(message, from: node)
|
||||
}
|
||||
link.onState = { [weak self, weak node] state in
|
||||
guard let self, let node, node.link === link else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
link.send(.hello(name: self.name, role: .control, version: RemoteProtocol.version))
|
||||
case .failed, .cancelled:
|
||||
if node.isConnected { log.notice("Control: lost \(node.id, privacy: .public)") }
|
||||
node.isConnected = false
|
||||
node.link = nil
|
||||
scheduleRetry(node)
|
||||
case .waiting:
|
||||
link.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
node.link = link
|
||||
link.start()
|
||||
}
|
||||
|
||||
private func scheduleRetry(_ node: Node) {
|
||||
node.retryTask = Task { [weak self, weak node] in
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
guard !Task.isCancelled, let self, let node, node.link == nil else { return }
|
||||
connect(node)
|
||||
}
|
||||
}
|
||||
|
||||
private func received(_ message: RemoteMessage, from node: Node) {
|
||||
switch message {
|
||||
case .hello(let name, let role, _):
|
||||
guard role == .node else { return }
|
||||
node.isConnected = true
|
||||
log.info("Control: connected to \(name, privacy: .public)")
|
||||
onNodeConnected?(node)
|
||||
case .status(let status):
|
||||
node.status = status
|
||||
case .apply, .show, .stop:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import MusubiCore
|
||||
import Network
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
/// Node side: advertises itself via Bonjour on the technical interface and accepts one control at a time.
|
||||
@MainActor @Observable
|
||||
public final class NodeServer {
|
||||
public private(set) var controlName: String?
|
||||
public private(set) var error: String?
|
||||
public private(set) var isListening = false
|
||||
|
||||
/// Called for `apply`, `show` and `stop` from the control.
|
||||
public var onCommand: ((RemoteMessage) -> Void)?
|
||||
|
||||
private let name: String
|
||||
private var listener: NWListener?
|
||||
private var link: Link?
|
||||
private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.node")
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote")
|
||||
|
||||
public init(name: String) {
|
||||
self.name = name
|
||||
}
|
||||
|
||||
public func start(interfaceName: String) async {
|
||||
stop()
|
||||
guard let interface = await NetworkInterfaces.interface(named: interfaceName) else {
|
||||
error = "Technik-Schnittstelle \(interfaceName) nicht gefunden."
|
||||
log.error("Node: interface \(interfaceName, privacy: .public) not found")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let listener = try NWListener(using: .musubi(interface: interface))
|
||||
listener.service = NWListener.Service(name: name, type: RemoteProtocol.serviceType)
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
Task { @MainActor in self?.accept(connection) }
|
||||
}
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in self?.listenerChanged(state) }
|
||||
}
|
||||
listener.start(queue: queue)
|
||||
self.listener = listener
|
||||
error = nil
|
||||
log.info("Node: advertising \(self.name, privacy: .public) on \(interfaceName, privacy: .public)")
|
||||
} catch {
|
||||
self.error = "Fernsteuerung lässt sich nicht starten."
|
||||
log.error("Node: listener failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
link?.cancel()
|
||||
link = nil
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
isListening = false
|
||||
controlName = nil
|
||||
}
|
||||
|
||||
public func send(_ status: NodeStatus) {
|
||||
guard controlName != nil else { return }
|
||||
link?.send(.status(status))
|
||||
}
|
||||
|
||||
private func listenerChanged(_ state: NWListener.State) {
|
||||
switch state {
|
||||
case .ready: isListening = true
|
||||
case .failed(let failure):
|
||||
isListening = false
|
||||
error = "Fernsteuerung gestört: \(failure.localizedDescription)"
|
||||
log.error("Node: listener failed: \(failure.localizedDescription, privacy: .public)")
|
||||
case .cancelled: isListening = false
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest control wins; the previous connection is closed.
|
||||
private func accept(_ connection: NWConnection) {
|
||||
link?.cancel()
|
||||
controlName = nil
|
||||
let link = Link(connection: connection, queue: queue)
|
||||
link.onMessage = { [weak self] in self?.received($0, from: link) }
|
||||
link.onState = { [weak self] state in
|
||||
guard let self, self.link === link else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
link.send(.hello(name: self.name, role: .node, version: RemoteProtocol.version))
|
||||
case .failed, .cancelled:
|
||||
log.notice("Node: control \(self.controlName ?? "?", privacy: .public) disconnected")
|
||||
self.controlName = nil
|
||||
self.link = nil
|
||||
default: break
|
||||
}
|
||||
}
|
||||
self.link = link
|
||||
link.start()
|
||||
}
|
||||
|
||||
private func received(_ message: RemoteMessage, from link: Link) {
|
||||
guard self.link === link else { return }
|
||||
switch message {
|
||||
case .hello(let name, _, _):
|
||||
controlName = name
|
||||
log.info("Node: controlled by \(name, privacy: .public)")
|
||||
case .apply, .show, .stop:
|
||||
onCommand?(message)
|
||||
case .status:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import Testing
|
||||
@testable import MusubiCore
|
||||
|
||||
@Suite struct ScreenMatchingTests {
|
||||
@Test func matchesByNameRegardlessOfOrder() {
|
||||
let ref = ScreenRef(name: "Field Monitor", index: 1)
|
||||
#expect(ScreenMatching.index(of: ref, in: ["Field Monitor", "HDMI TO USB"]) == 0)
|
||||
}
|
||||
|
||||
@Test func identicalMonitorsUsePosition() {
|
||||
let names = ["LG", "LG", "Built-in"]
|
||||
#expect(ScreenMatching.index(of: ScreenRef(name: "LG", index: 1), in: names) == 1)
|
||||
#expect(ScreenMatching.index(of: ScreenRef(name: "LG", index: 2), in: names) == 0)
|
||||
}
|
||||
|
||||
@Test func missingScreenIsNil() {
|
||||
#expect(ScreenMatching.index(of: ScreenRef(name: "Beamer", index: 0), in: ["Field Monitor"]) == nil)
|
||||
}
|
||||
|
||||
@Test func resolveKeepsFirstClaim() {
|
||||
let a = Source(ndiName: "A (a)"), b = Source(ndiName: "B (b)")
|
||||
let resolved = ScreenMatching.resolve([
|
||||
VideoAssignment(screen: ScreenRef(name: "X", index: 0), source: a),
|
||||
VideoAssignment(screen: ScreenRef(name: "X", index: 0), source: b),
|
||||
VideoAssignment(screen: ScreenRef(name: "Gone", index: 3), source: b),
|
||||
], in: ["X"])
|
||||
#expect(resolved == [0: a])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct ReconcileTests {
|
||||
@Test func stopsRemovedStartsNewAndChanged() {
|
||||
let plan = Reconcile.plan(current: [1: "A", 2: "B", 3: "C"], desired: [2: "B", 3: "X", 4: "D"])
|
||||
#expect(Set(plan.stop) == [1])
|
||||
#expect(plan.start == [3: "X", 4: "D"])
|
||||
}
|
||||
|
||||
@Test func nothingToDoWhenEqual() {
|
||||
let plan = Reconcile.plan(current: [1: "A"], desired: [1: "A"])
|
||||
#expect(plan.stop.isEmpty)
|
||||
#expect(plan.start.isEmpty)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MusubiCore
|
||||
@testable import Remote
|
||||
|
||||
@Suite struct FramingTests {
|
||||
let show = RemoteMessage.show(VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: Source(ndiName: "PGM (pgm)")))
|
||||
|
||||
@Test func roundTripsMessages() throws {
|
||||
var framer = MessageFramer()
|
||||
let data = try MessageFramer.encode(.hello(name: "MacBook", role: .control, version: 1)) + MessageFramer.encode(show)
|
||||
#expect(try framer.append(data) == [.hello(name: "MacBook", role: .control, version: 1), show])
|
||||
}
|
||||
|
||||
@Test func reassemblesSplitData() throws {
|
||||
var framer = MessageFramer()
|
||||
let data = try MessageFramer.encode(show)
|
||||
var received: [RemoteMessage] = []
|
||||
for byte in data { received += try framer.append(Data([byte])) }
|
||||
#expect(received == [show])
|
||||
}
|
||||
|
||||
@Test func skipsMalformedBody() throws {
|
||||
var framer = MessageFramer()
|
||||
var bad = Data([0, 0, 0, 3]) + Data("{x}".utf8)
|
||||
bad += try MessageFramer.encode(.stop(ScreenRef(name: "A", index: 0)))
|
||||
#expect(try framer.append(bad) == [.stop(ScreenRef(name: "A", index: 0))])
|
||||
}
|
||||
|
||||
@Test func rejectsHugeLength() {
|
||||
var framer = MessageFramer()
|
||||
#expect(throws: MessageFramer.FramingError.self) {
|
||||
try framer.append(Data([0xFF, 0xFF, 0xFF, 0xFF]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func statusRoundTrips() throws {
|
||||
let status = NodeStatus(
|
||||
screens: [ScreenStatus(screen: ScreenRef(name: "Field Monitor", index: 1), width: 1920, height: 1080, refreshRate: 50,
|
||||
output: OutputStatus(source: Source(ndiName: "PGM (pgm)"), state: .live, width: 1920, height: 1080,
|
||||
frameRate: 50, lostFrames: 3, lossRate: 0.0001))],
|
||||
config: MachineConfig(video: [VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: Source(ndiName: "PGM (pgm)"))]),
|
||||
cpuPercent: 51.2, sleepBlocked: true)
|
||||
var framer = MessageFramer()
|
||||
#expect(try framer.append(MessageFramer.encode(.status(status))) == [.status(status)])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue