Files
Tonuino-Manager/Sources/TonuinoManager/Services/TonuinoSerialService.swift
T

304 lines
14 KiB
Swift

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<Void, Never>?
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[..<newline]); buffer.removeSubrange(...newline); lock.unlock()
return String(data: line, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
lock.unlock()
}
}
}
return nil
}
}
/// Values printed by current TonUINO-TNG AiO Plus firmware during startup.
/// Older AiO firmware may not print these lines, so every field is optional.
struct TonuinoDeviceSettings: Equatable {
var firmwareSettingsVersion: Int?
var speakerMaximum: Int?
var speakerMinimum: Int?
var speakerInitial: Int?
var headphoneMaximum: Int?
var headphoneMinimum: Int?
var headphoneInitial: Int?
var equalizer: Int?
var standbyTimer: Int?
var invertedVolumeButtons: Int?
var adminMenuLocked: Int?
var pauseWhenCardRemoved: Int?
var hasValues: Bool {
firmwareSettingsVersion != nil || speakerMaximum != nil || speakerMinimum != nil || speakerInitial != nil ||
headphoneMaximum != nil || headphoneMinimum != nil || headphoneInitial != nil || equalizer != nil ||
standbyTimer != nil || invertedVolumeButtons != nil || adminMenuLocked != nil || pauseWhenCardRemoved != nil
}
mutating func readLogLine(_ line: String) {
if let values = values(after: "Ver:", in: line), let first = values.first { firmwareSettingsVersion = first }
if let values = values(after: "Vol:", in: line) {
if values.indices.contains(0) { speakerMaximum = values[0] }
if values.indices.contains(1) { speakerMinimum = values[1] }
if values.indices.contains(2) { speakerInitial = values[2] }
if values.indices.contains(3) { headphoneMaximum = values[3] }
if values.indices.contains(4) { headphoneMinimum = values[4] }
if values.indices.contains(5) { headphoneInitial = values[5] }
}
if let values = values(after: "EQ:", in: line), let first = values.first { equalizer = first }
if let values = values(after: "ST:", in: line), let first = values.first { standbyTimer = first }
if let values = values(after: "IB:", in: line), let first = values.first { invertedVolumeButtons = first }
if let values = values(after: "AL:", in: line), let first = values.first { adminMenuLocked = first }
if let values = values(after: "PCR:", in: line), let first = values.first { pauseWhenCardRemoved = first }
// AP (the admin PIN) is intentionally neither parsed nor retained.
}
private func values(after marker: String, in line: String) -> [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
}
}
}