import Darwin import Foundation final class TonuinoSerialService: @unchecked Sendable { private let lock = NSLock() private var descriptor: Int32 = -1 private var buffer = Data() private var outputMonitor: Task? private(set) var firmwareVersion: String? static var ports: [String] { ((try? FileManager.default.contentsOfDirectory(atPath: "/dev")) ?? []) .filter { $0.hasPrefix("cu.") }.map { "/dev/\($0)" }.sorted() } var isConnected: Bool { lock.lock(); defer { lock.unlock() }; return descriptor >= 0 } func connect(_ path: String) throws -> (firmwareVersion: String?, settings: TonuinoDeviceSettings?) { disconnect() let fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK) guard fd >= 0 else { throw SerialError.open(path, String(cString: strerror(errno))) } var termiosConfig = termios() guard tcgetattr(fd, &termiosConfig) == 0 else { close(fd); throw SerialError.configure } cfmakeraw(&termiosConfig); cfsetispeed(&termiosConfig, speed_t(B115200)); cfsetospeed(&termiosConfig, speed_t(B115200)) termiosConfig.c_cflag |= tcflag_t(CLOCAL | CREAD) termiosConfig.c_cflag &= ~tcflag_t(CSTOPB | PARENB | CSIZE) termiosConfig.c_cflag |= tcflag_t(CS8) guard tcsetattr(fd, TCSANOW, &termiosConfig) == 0 else { close(fd); throw SerialError.configure } tcflush(fd, TCIOFLUSH) lock.lock(); descriptor = fd; buffer.removeAll(); lock.unlock() firmwareVersion = nil var parsedSettings = TonuinoDeviceSettings() let startupDeadline = Date().addingTimeInterval(3) // many TonUINO boards reset when serial is opened while Date() < startupDeadline { let remaining = startupDeadline.timeIntervalSinceNow guard let line = try readLine(timeout: min(0.15, max(remaining, 0))) else { continue } if let range = line.range(of: #"V\d+\.\d+\.\d+[^\r\n]*"#, options: .regularExpression) { firmwareVersion = String(line[range]) } parsedSettings.readLogLine(line) } let capturedSettings = parsedSettings.hasValues ? parsedSettings : nil outputMonitor = Task.detached(priority: .utility) { [weak self] in while !Task.isCancelled { guard let self else { return } do { _ = try self.readLine(timeout: 0.3) } catch { return } } } return (firmwareVersion, capturedSettings) } func disconnect() { outputMonitor?.cancel() outputMonitor = nil lock.lock(); let old = descriptor; descriptor = -1; buffer.removeAll(); lock.unlock() if old >= 0 { close(old) } } func writeCard(_ card: TonuinoCard) async throws { try card.validate() guard card.modifier == nil else { throw SerialError.invalid("Sonderkarten können mit diesem seriellen Schreibassistenten nicht programmiert werden. Verwende einen PC/SC-Reader oder exportiere einen Dump.") } // TonUINO-TNG has no WRITECARD serial command. With SerialInputAsCommand // enabled, its documented numeric commands can drive the built-in // Admin > New card voice menu instead. try write("-4\n") // allLong: open the admin menu while TonUINO is idle try await Task.sleep(for: .milliseconds(500)) try await selectMenuItem(1) // Admin: create new card let mode = card.mode == .admin ? 6 : card.mode.rawValue try await selectMenuItem(mode) if card.mode.needsFolder, ![.admin, .repeatLast, .bluetooth].contains(card.mode) { try await selectMenuItem(Int(card.folder)) } switch card.mode { case .single: try await selectMenuItem(Int(card.special)) case .hoerspielRange, .albumRange, .partyRange, .audiobookRange: try await selectMenuItem(Int(card.special)) try await selectMenuItem(Int(card.special2)) case .audiobook: try await selectMenuItem(Int(card.folder) + Int(card.special2)) case .audiobookSingle: try await selectMenuItem(Int(card.folder) + Int(card.special2)) try await selectMenuItem(Int(card.special) + 1) case .quiz: let option: Int switch (card.special, card.special2) { case (2, 0): option = 1 case (4, 0): option = 2 case (2, 1): option = 3 case (4, 1): option = 4 case (0, 1): option = 5 default: throw SerialError.invalid("Ungültige Quiz-Optionen.") } try await selectMenuItem(option) default: break } } func cancelWrite() throws { try exitAdminMenu() } func exitAdminMenu() throws { try write("-6\n") } func applyAdminSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws { guard (2...14).contains(menuItem), (1...99).contains(option) else { throw SerialError.invalid("Ungültige TonUINO-Menüauswahl.") } if menuItem == 12, option == 3 { try validatePIN(newPIN) } try await enterAdmin(currentPIN: currentPIN) try await selectMenuItem(menuItem) try await selectMenuItem(option) if menuItem == 12, option == 3 { try await sendPIN(newPIN) } } func applyVolumeSettings(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws { guard (1...29).contains(minimum), (2...30).contains(maximum), minimum < maximum, (minimum...maximum).contains(initial) else { throw SerialError.invalid("Lautstärke: Minimum muss unter dem Maximum liegen; Startlautstärke muss dazwischen liegen.") } try await enterAdmin(currentPIN: currentPIN) // Set a known intermediate range so subsequent voice-menu indices are deterministic. try await selectMenuItem(3) // Minimum volume try await selectMenuItem(1) try await selectMenuItem(2) // Maximum volume try await selectMenuItem(29) // 30, now that minimum is 1 try await selectMenuItem(3) try await selectMenuItem(minimum) try await selectMenuItem(2) try await selectMenuItem(maximum - minimum) try await selectMenuItem(4) try await selectMenuItem(initial - minimum + 1) } func resetAdminSettings(currentPIN: String = "") async throws { try await enterAdmin(currentPIN: currentPIN) try await selectMenuItem(11) // The reset state consumes one command to return to Admin_Entry. try await Task.sleep(for: .milliseconds(250)) try write("-6\n") try await Task.sleep(for: .milliseconds(250)) try write("-6\n") try await Task.sleep(for: .milliseconds(250)) } private func enterAdmin(currentPIN: String) async throws { if !currentPIN.isEmpty { try validatePIN(currentPIN) } try write("-4\n") // allLong: enter Admin while idle try await Task.sleep(for: .milliseconds(500)) if !currentPIN.isEmpty { try await sendPIN(currentPIN) } } private func sendPIN(_ pin: String) async throws { let commands: [Character: Int] = ["1": -5, "2": -8, "3": -2] for digit in pin { try Task.checkCancellation() try write("\(commands[digit]!)\n") try await Task.sleep(for: .milliseconds(220)) } try await Task.sleep(for: .milliseconds(250)) } private func validatePIN(_ pin: String) throws { guard pin.count == 4, pin.allSatisfy({ ["1", "2", "3"].contains(String($0)) }) else { throw SerialError.invalid("Die TonUINO-PIN muss aus vier Ziffern 1, 2 oder 3 bestehen.") } } private func selectMenuItem(_ value: Int) async throws { try Task.checkCancellation() try write("\(value)\n") try await Task.sleep(for: .milliseconds(180)) try write("-5\n") // pause: select the current voice-menu item try await Task.sleep(for: .milliseconds(250)) } private func write(_ text: String) throws { lock.lock(); defer { lock.unlock() } guard descriptor >= 0 else { throw SerialError.disconnected } let bytes = Array(text.utf8) let result = Darwin.write(descriptor, bytes, bytes.count) guard result == bytes.count else { throw SerialError.io(String(cString: strerror(errno))) } } private func readLine(timeout: TimeInterval) throws -> String? { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { lock.lock(); let fd = descriptor; lock.unlock() guard fd >= 0 else { throw SerialError.disconnected } var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0) if Darwin.poll(&pollDescriptor, 1, 100) > 0 { var bytes = [UInt8](repeating: 0, count: 256) let count = Darwin.read(fd, &bytes, bytes.count) if count > 0 { lock.lock(); buffer.append(contentsOf: bytes.prefix(count)) if let newline = buffer.firstIndex(of: 10) { let line = Data(buffer[.. [Int]? { guard let range = line.range(of: NSRegularExpression.escapedPattern(for: marker), options: [.regularExpression, .caseInsensitive]) else { return nil } let remainder = line[range.upperBound...] let text = String(remainder) let pattern = #"-?\d+"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let nsRange = NSRange(text.startIndex..., in: text) return regex.matches(in: text, range: nsRange).compactMap { match in guard let matchRange = Range(match.range, in: text) else { return nil } return Int(text[matchRange]) } } } extension TonuinoCard { func validate() throws { if let modifier { switch modifier { case .sleepTimer: guard special > 0 else { throw SerialError.invalid("Der Einschlaf-Timer muss mindestens eine Minute dauern.") } case .freezeDance, .fireWaterAir: guard special <= 2 else { throw SerialError.invalid("Die Pausenstufe muss zwischen 0 und 2 liegen.") } default: break } return } if mode.needsFolder, mode != .admin, !(1...99).contains(Int(folder)) { throw SerialError.invalid("Ordner muss zwischen 01 und 99 liegen.") } if mode == .single && special == 0 { throw SerialError.invalid("Bitte einen Track größer 0 auswählen.") } if mode == .audiobookSingle && special >= 30 { throw SerialError.invalid("Hörbuch einzeln akzeptiert maximal 29 Zusatztracks.") } if [.hoerspielRange, .albumRange, .partyRange, .audiobookRange].contains(mode), special == 0 || special > special2 { throw SerialError.invalid("Der Trackbereich muss von 1 bis zum Endtrack reichen.") } if mode == .quiz && !([0, 2, 4].contains(Int(special)) && [0, 1].contains(Int(special2))) { throw SerialError.invalid("Ungültige Quiz-Optionen.") } } } enum SerialError: LocalizedError { case open(String, String), configure, disconnected, io(String), timeout, firmware(String), invalid(String) var errorDescription: String? { switch self { case .open(let p, let e): "Serieller Port \(p) kann nicht geöffnet werden: \(e)" case .configure: "Der serielle Port konnte nicht auf 115200 Baud konfiguriert werden." case .disconnected: "Keine Verbindung zum TonUINO." case .io(let e): "Serielle Kommunikation fehlgeschlagen: \(e)" case .timeout: "Zeitüberschreitung. Prüfe, ob keine Karte auf dem TonUINO-Leser liegt und die Firmware SerialInputAsCommand aktiviert hat." case .firmware(let text): text case .invalid(let text): text } } }