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 <noreply@anthropic.com>
main
Samuel Zielke 3 days ago
parent cf561d410c
commit 02c06f7105

@ -11,6 +11,7 @@ let package = Package(
.library(name: "VideoOutput", targets: ["VideoOutput"]), .library(name: "VideoOutput", targets: ["VideoOutput"]),
.library(name: "AudioMatrix", targets: ["AudioMatrix"]), .library(name: "AudioMatrix", targets: ["AudioMatrix"]),
.library(name: "DesignSystem", targets: ["DesignSystem"]), .library(name: "DesignSystem", targets: ["DesignSystem"]),
.library(name: "Remote", targets: ["Remote"]),
], ],
targets: [ targets: [
// Pure logic: models, scenes, settings. // 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). // Theme.swift is a symlink to design/Theme.swift (single source of truth).
.target(name: "DesignSystem"), .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: "MusubiCoreTests", dependencies: ["MusubiCore"]),
.testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]), .testTarget(name: "AudioMatrixTests", dependencies: ["AudioMatrix", "CAudioRing"]),
.testTarget(name: "RemoteTests", dependencies: ["Remote", "MusubiCore"]),
] ]
) )

@ -1,5 +1,6 @@
import CAudioRing import CAudioRing
import Foundation import Foundation
import MusubiCore
import Observation import Observation
import OSLog import OSLog

@ -12,17 +12,3 @@ public enum Level {
gain <= 0 ? floorDB : max(floorDB, 20 * log10f(gain)) 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)
}
}
}

@ -1,5 +1,6 @@
import CAudioRing import CAudioRing
import Foundation import Foundation
import MusubiCore
/// Real-time state of one route: feed → resampler → gain → one output channel. /// Real-time state of one route: feed → resampler → gain → one output channel.
/// ///

@ -21,37 +21,23 @@ public struct Source: Codable, Hashable, Sendable {
} }
} }
public enum Output: Codable, Hashable, Sendable { public enum Role: String, Codable, CaseIterable, Sendable {
case screen(displayID: UInt32) /// MacBook at the control desk: controls nodes, owns scenes.
case audioChannel(deviceUID: String, channel: Int) case control
/// Mac mini on stage: follows the control, keeps its last state on its own.
case node
} }
public struct Route: Codable, Hashable, Sendable { /// Which part of a stereo source feeds a mono output channel.
public enum Mode: String, Codable, Sendable { case videoAndAudio, videoOnly, audioOnly } public enum ChannelSelection: String, Codable, CaseIterable, Sendable {
case left, right, mono
public var source: Source
public var output: Output @inline(__always)
public var mode: Mode public func sample(left: Float, right: Float) -> Float {
switch self {
public init(source: Source, output: Output, mode: Mode) { case .left: left
self.source = source case .right: right
self.output = output case .mono: 0.5 * (left + right)
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
} }
} }

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

@ -2,6 +2,7 @@ import CAudioRing
import Foundation import Foundation
import Testing import Testing
@testable import AudioMatrix @testable import AudioMatrix
import MusubiCore
@Suite struct RingTests { @Suite struct RingTests {
@Test func writesAndReadsAcrossWrap() throws { @Test func writesAndReadsAcrossWrap() throws {

@ -5,10 +5,18 @@ import Testing
@Suite struct ModelTests { @Suite struct ModelTests {
@Test func sceneRoundTripsThroughJSON() throws { @Test func sceneRoundTripsThroughJSON() throws {
let pgm = Source(ndiName: "ZOWIEBOX-PGM (PGM)") let pgm = Source(ndiName: "ZOWIEBOX-PGM (PGM)")
let scene = Scene(name: "OP-Live", role: .node, routes: [ let mv = Source(ndiName: "ZOWIEBOX-MV (MV)")
Route(source: pgm, output: .screen(displayID: 2), mode: .videoOnly), let scene = Scene(
Route(source: pgm, output: .audioChannel(deviceUID: "MOTU-M2", channel: 2), mode: .audioOnly), 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 data = try JSONEncoder().encode(scene)
let decoded = try JSONDecoder().decode(Scene.self, from: data) let decoded = try JSONDecoder().decode(Scene.self, from: data)
@ -16,6 +24,15 @@ import Testing
#expect(decoded == scene) #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: [ @Test(arguments: [
("ZOWIEBOX-PGM (PGM)", "PGM"), ("ZOWIEBOX-PGM (PGM)", "PGM"),
("MACBOOK (Test Pattern)", "Test Pattern"), ("MACBOOK (Test Pattern)", "Test Pattern"),

@ -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…
Cancel
Save

Powered by TurnKey Linux.