From 02c06f71050597979d5b399bd13394bb074c2c5b Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Wed, 21 Oct 2026 15:44:26 +0200 Subject: [PATCH] feat(remote): scene model, screen matching and Bonjour control/node link - 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 --- Packages/MusubiKit/Package.swift | 5 + .../Sources/AudioMatrix/AudioMatrix.swift | 1 + .../MusubiKit/Sources/AudioMatrix/Level.swift | 14 -- .../Sources/AudioMatrix/RouteRenderer.swift | 1 + .../MusubiKit/Sources/MusubiCore/Models.swift | 46 ++---- .../MusubiKit/Sources/MusubiCore/Scene.swift | 91 +++++++++++ .../Sources/MusubiCore/ScreenMatching.swift | 42 +++++ .../MusubiKit/Sources/Remote/Interfaces.swift | 54 +++++++ Packages/MusubiKit/Sources/Remote/Link.swift | 85 ++++++++++ .../MusubiKit/Sources/Remote/Messages.swift | 108 +++++++++++++ .../Sources/Remote/NodeBrowser.swift | 147 ++++++++++++++++++ .../MusubiKit/Sources/Remote/NodeServer.swift | 113 ++++++++++++++ .../Tests/AudioMatrixTests/DSPTests.swift | 1 + .../Tests/MusubiCoreTests/ModelTests.swift | 25 ++- .../Tests/MusubiCoreTests/SceneTests.swift | 43 +++++ .../Tests/RemoteTests/FramingTests.swift | 47 ++++++ 16 files changed, 775 insertions(+), 48 deletions(-) create mode 100644 Packages/MusubiKit/Sources/MusubiCore/Scene.swift create mode 100644 Packages/MusubiKit/Sources/MusubiCore/ScreenMatching.swift create mode 100644 Packages/MusubiKit/Sources/Remote/Interfaces.swift create mode 100644 Packages/MusubiKit/Sources/Remote/Link.swift create mode 100644 Packages/MusubiKit/Sources/Remote/Messages.swift create mode 100644 Packages/MusubiKit/Sources/Remote/NodeBrowser.swift create mode 100644 Packages/MusubiKit/Sources/Remote/NodeServer.swift create mode 100644 Packages/MusubiKit/Tests/MusubiCoreTests/SceneTests.swift create mode 100644 Packages/MusubiKit/Tests/RemoteTests/FramingTests.swift diff --git a/Packages/MusubiKit/Package.swift b/Packages/MusubiKit/Package.swift index 313af08..8570a6e 100644 --- a/Packages/MusubiKit/Package.swift +++ b/Packages/MusubiKit/Package.swift @@ -11,6 +11,7 @@ let package = Package( .library(name: "VideoOutput", targets: ["VideoOutput"]), .library(name: "AudioMatrix", targets: ["AudioMatrix"]), .library(name: "DesignSystem", targets: ["DesignSystem"]), + .library(name: "Remote", targets: ["Remote"]), ], targets: [ // Pure logic: models, scenes, settings. @@ -28,7 +29,11 @@ let package = Package( // Theme.swift is a symlink to design/Theme.swift (single source of truth). .target(name: "DesignSystem"), + // Control ↔ node over Bonjour/TCP on the technical interface (M3). + .target(name: "Remote", dependencies: ["MusubiCore"]), + .testTarget(name: "MusubiCoreTests", dependencies: ["MusubiCore"]), .testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]), + .testTarget(name: "RemoteTests", dependencies: ["Remote", "MusubiCore"]), ] ) diff --git a/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift b/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift index 9c11758..a0bc1fc 100644 --- a/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift +++ b/Packages/MusubiKit/Sources/AudioMatrix/AudioMatrix.swift @@ -1,5 +1,6 @@ import CAudioRing import Foundation +import MusubiCore import Observation import OSLog diff --git a/Packages/MusubiKit/Sources/AudioMatrix/Level.swift b/Packages/MusubiKit/Sources/AudioMatrix/Level.swift index 185f3e1..f315c51 100644 --- a/Packages/MusubiKit/Sources/AudioMatrix/Level.swift +++ b/Packages/MusubiKit/Sources/AudioMatrix/Level.swift @@ -12,17 +12,3 @@ public enum Level { gain <= 0 ? floorDB : max(floorDB, 20 * log10f(gain)) } } - -/// Which part of a stereo source feeds a mono output channel. -public enum ChannelSelection: String, Codable, CaseIterable, Sendable { - case left, right, mono - - @inline(__always) - public func sample(left: Float, right: Float) -> Float { - switch self { - case .left: left - case .right: right - case .mono: 0.5 * (left + right) - } - } -} diff --git a/Packages/MusubiKit/Sources/AudioMatrix/RouteRenderer.swift b/Packages/MusubiKit/Sources/AudioMatrix/RouteRenderer.swift index bf40b09..b40439c 100644 --- a/Packages/MusubiKit/Sources/AudioMatrix/RouteRenderer.swift +++ b/Packages/MusubiKit/Sources/AudioMatrix/RouteRenderer.swift @@ -1,5 +1,6 @@ import CAudioRing import Foundation +import MusubiCore /// Real-time state of one route: feed → resampler → gain → one output channel. /// diff --git a/Packages/MusubiKit/Sources/MusubiCore/Models.swift b/Packages/MusubiKit/Sources/MusubiCore/Models.swift index e89c5f4..dbe78fb 100644 --- a/Packages/MusubiKit/Sources/MusubiCore/Models.swift +++ b/Packages/MusubiKit/Sources/MusubiCore/Models.swift @@ -21,37 +21,23 @@ public struct Source: Codable, Hashable, Sendable { } } -public enum Output: Codable, Hashable, Sendable { - case screen(displayID: UInt32) - case audioChannel(deviceUID: String, channel: Int) +public enum Role: String, Codable, CaseIterable, Sendable { + /// MacBook at the control desk: controls nodes, owns scenes. + case control + /// Mac mini on stage: follows the control, keeps its last state on its own. + case node } -public struct Route: Codable, Hashable, Sendable { - public enum Mode: String, Codable, Sendable { case videoAndAudio, videoOnly, audioOnly } - - public var source: Source - public var output: Output - public var mode: Mode - - public init(source: Source, output: Output, mode: Mode) { - self.source = source - self.output = output - self.mode = mode - } -} - -public enum Role: String, Codable, Sendable { case control, node } - -public struct Scene: Codable, Identifiable, Hashable, Sendable { - public var id: UUID - public var name: String - public var role: Role - public var routes: [Route] - - public init(id: UUID = UUID(), name: String, role: Role, routes: [Route] = []) { - self.id = id - self.name = name - self.role = role - self.routes = routes +/// Which part of a stereo source feeds a mono output channel. +public enum ChannelSelection: String, Codable, CaseIterable, Sendable { + case left, right, mono + + @inline(__always) + public func sample(left: Float, right: Float) -> Float { + switch self { + case .left: left + case .right: right + case .mono: 0.5 * (left + right) + } } } diff --git a/Packages/MusubiKit/Sources/MusubiCore/Scene.swift b/Packages/MusubiKit/Sources/MusubiCore/Scene.swift new file mode 100644 index 0000000..8912994 --- /dev/null +++ b/Packages/MusubiKit/Sources/MusubiCore/Scene.swift @@ -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 + } +} diff --git a/Packages/MusubiKit/Sources/MusubiCore/ScreenMatching.swift b/Packages/MusubiKit/Sources/MusubiCore/ScreenMatching.swift new file mode 100644 index 0000000..6d3cb0c --- /dev/null +++ b/Packages/MusubiKit/Sources/MusubiCore/ScreenMatching.swift @@ -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 { + public var stop: [Key] + public var start: [Key: Value] + } + + public static func plan(current: [Key: Value], desired: [Key: Value]) -> Plan { + let stop = current.keys.filter { desired[$0] == nil } + let start = desired.filter { current[$0.key] != $0.value } + return Plan(stop: stop, start: start) + } +} diff --git a/Packages/MusubiKit/Sources/Remote/Interfaces.swift b/Packages/MusubiKit/Sources/Remote/Interfaces.swift new file mode 100644 index 0000000..1cbc72d --- /dev/null +++ b/Packages/MusubiKit/Sources/Remote/Interfaces.swift @@ -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 } } +} diff --git a/Packages/MusubiKit/Sources/Remote/Link.swift b/Packages/MusubiKit/Sources/Remote/Link.swift new file mode 100644 index 0000000..0e73de1 --- /dev/null +++ b/Packages/MusubiKit/Sources/Remote/Link.swift @@ -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 + } +} diff --git a/Packages/MusubiKit/Sources/Remote/Messages.swift b/Packages/MusubiKit/Sources/Remote/Messages.swift new file mode 100644 index 0000000..26adfcd --- /dev/null +++ b/Packages/MusubiKit/Sources/Remote/Messages.swift @@ -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 + } +} diff --git a/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift b/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift new file mode 100644 index 0000000..69c6d10 --- /dev/null +++ b/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift @@ -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? + + 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 + } + } +} diff --git a/Packages/MusubiKit/Sources/Remote/NodeServer.swift b/Packages/MusubiKit/Sources/Remote/NodeServer.swift new file mode 100644 index 0000000..17b86cc --- /dev/null +++ b/Packages/MusubiKit/Sources/Remote/NodeServer.swift @@ -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 + } + } +} diff --git a/Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift b/Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift index 7e1e3db..a402703 100644 --- a/Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift +++ b/Packages/MusubiKit/Tests/AudioMatrixTests/DSPTests.swift @@ -2,6 +2,7 @@ import CAudioRing import Foundation import Testing @testable import AudioMatrix +import MusubiCore @Suite struct RingTests { @Test func writesAndReadsAcrossWrap() throws { diff --git a/Packages/MusubiKit/Tests/MusubiCoreTests/ModelTests.swift b/Packages/MusubiKit/Tests/MusubiCoreTests/ModelTests.swift index 050e392..826f401 100644 --- a/Packages/MusubiKit/Tests/MusubiCoreTests/ModelTests.swift +++ b/Packages/MusubiKit/Tests/MusubiCoreTests/ModelTests.swift @@ -5,10 +5,18 @@ import Testing @Suite struct ModelTests { @Test func sceneRoundTripsThroughJSON() throws { let pgm = Source(ndiName: "ZOWIEBOX-PGM (PGM)") - let scene = Scene(name: "OP-Live", role: .node, routes: [ - Route(source: pgm, output: .screen(displayID: 2), mode: .videoOnly), - Route(source: pgm, output: .audioChannel(deviceUID: "MOTU-M2", channel: 2), mode: .audioOnly), - ]) + let mv = Source(ndiName: "ZOWIEBOX-MV (MV)") + let scene = Scene( + name: "OP-Live", + control: MachineConfig(audio: [ + AudioAssignment(source: .ndi(name: pgm.ndiName), deviceUID: "MOTU-UID", deviceName: "M2", channel: 1, gainDB: -6), + AudioAssignment(source: .input(uid: "POLY-UID", name: "Poly BT700"), deviceUID: "MOTU-UID", deviceName: "M2", channel: 0), + ]), + nodes: ["Mac mini von Samuel": MachineConfig(video: [ + VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: pgm), + VideoAssignment(screen: ScreenRef(name: "HDMI TO USB", index: 0), source: mv), + ])] + ) let data = try JSONEncoder().encode(scene) let decoded = try JSONDecoder().decode(Scene.self, from: data) @@ -16,6 +24,15 @@ import Testing #expect(decoded == scene) } + @Test func audioKeyIgnoresSettings() { + var a = AudioAssignment(source: .ndi(name: "X"), deviceUID: "D", deviceName: "M2", channel: 1) + let key = a.key + a.gainDB = -12 + a.muted = true + #expect(a.key == key) + #expect(key == "ndi:X→D#1") + } + @Test(arguments: [ ("ZOWIEBOX-PGM (PGM)", "PGM"), ("MACBOOK (Test Pattern)", "Test Pattern"), diff --git a/Packages/MusubiKit/Tests/MusubiCoreTests/SceneTests.swift b/Packages/MusubiKit/Tests/MusubiCoreTests/SceneTests.swift new file mode 100644 index 0000000..b292f82 --- /dev/null +++ b/Packages/MusubiKit/Tests/MusubiCoreTests/SceneTests.swift @@ -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) + } +} diff --git a/Packages/MusubiKit/Tests/RemoteTests/FramingTests.swift b/Packages/MusubiKit/Tests/RemoteTests/FramingTests.swift new file mode 100644 index 0000000..52b21a4 --- /dev/null +++ b/Packages/MusubiKit/Tests/RemoteTests/FramingTests.swift @@ -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)]) + } +}